1 # Copyright (c) Microsoft Corporation. All rights reserved. 2 # Licensed under the MIT License. See LICENSE in the project root 3 # for license information. 4 5 from __future__ import annotations 6 7 import os 8 import subprocess 9 import sys 10 import threading 11 import time 12 13 import debugpy 14 from debugpy import adapter 15 from debugpy.common import json, log, messaging, sockets 16 from debugpy.adapter import components, sessions 17 import traceback 18 import io 19 20 access_token = None 21 """Access token used to authenticate with the servers.""" 22 23 listener = None 24 """Listener socket that accepts server connections.""" 25 26 _lock = threading.RLock() 27 28 _connections = [] 29 """All servers that are connected to this adapter, in order in which they connected. 30 """ 31 32 _connections_changed = threading.Event() 33 34 35 class Connection(object): 36 """A debug server that is connected to the adapter. 37 38 Servers that are not participating in a debug session are managed directly by the 39 corresponding Connection instance. 40 41 Servers that are participating in a debug session are managed by that sessions's 42 Server component instance, but Connection object remains, and takes over again 43 once the session ends. 44 """ 45 46 disconnected: bool 47 48 process_replaced: bool 49 """Whether this is a connection to a process that is being replaced in situ 50 by another process, e.g. via exec(). 51 """ 52 53 server: Server | None 54 """The Server component, if this debug server belongs to Session. 55 """ 56 57 pid: int | None 58 59 ppid: int | None 60 61 channel: messaging.JsonMessageChannel 62 63 def __init__(self, sock): 64 from debugpy.adapter import sessions 65 66 self.disconnected = False 67 68 self.process_replaced = False 69 70 self.server = None 71 72 self.pid = None 73 74 stream = messaging.JsonIOStream.from_socket(sock, str(self)) 75 self.channel = messaging.JsonMessageChannel(stream, self) 76 self.channel.start() 77 78 try: 79 self.authenticate() 80 info = self.channel.request("pydevdSystemInfo") 81 process_info = info("process", json.object()) 82 self.pid = process_info("pid", int) 83 self.ppid = process_info("ppid", int, optional=True) 84 if self.ppid == (): 85 self.ppid = None 86 self.channel.name = stream.name = str(self) 87 88 with _lock: 89 # The server can disconnect concurrently before we get here, e.g. if 90 # it was force-killed. If the disconnect() handler has already run, 91 # don't register this server or report it, since there's nothing to 92 # deregister it. 93 if self.disconnected: 94 return 95 96 # An existing connection with the same PID and process_replaced == True 97 # corresponds to the process that replaced itself with this one, so it's 98 # not an error. 99 if any( 100 conn.pid == self.pid and not conn.process_replaced 101 for conn in _connections 102 ): 103 raise KeyError(f"{self} is already connected to this adapter") 104 105 is_first_server = len(_connections) == 0 106 _connections.append(self) 107 _connections_changed.set() 108 109 except Exception: 110 log.swallow_exception("Failed to accept incoming server connection:") 111 self.channel.close() 112 113 # If this was the first server to connect, and the main thread is inside 114 # wait_until_disconnected(), we want to unblock it and allow it to exit. 115 dont_wait_for_first_connection() 116 117 # If we couldn't retrieve all the necessary info from the debug server, 118 # or there's a PID clash, we don't want to track this debuggee anymore, 119 # but we want to continue accepting connections. 120 return 121 122 parent_session = sessions.get(self.ppid) 123 if parent_session is None: 124 parent_session = sessions.get(self.pid) 125 if parent_session is None: 126 log.info("No active debug session for parent process of {0}.", self) 127 else: 128 if self.pid == parent_session.pid: 129 parent_server = parent_session.server 130 if not (parent_server and parent_server.connection.process_replaced): 131 log.error("{0} is not expecting replacement.", parent_session) 132 self.channel.close() 133 return 134 try: 135 parent_session.client.notify_of_subprocess(self) 136 return 137 except Exception: 138 # This might fail if the client concurrently disconnects from the parent 139 # session. We still want to keep the connection around, in case the 140 # client reconnects later. If the parent session was "launch", it'll take 141 # care of closing the remaining server connections. 142 log.swallow_exception( 143 "Failed to notify parent session about {0}:", self 144 ) 145 146 # If we got to this point, the subprocess notification was either not sent, 147 # or not delivered successfully. For the first server, this is expected, since 148 # it corresponds to the root process, and there is no other debug session to 149 # notify. But subsequent server connections represent subprocesses, and those 150 # will not start running user code until the client tells them to. Since there 151 # isn't going to be a client without the notification, such subprocesses have 152 # to be unblocked. 153 if is_first_server: 154 return 155 log.info("No clients to wait for - unblocking {0}.", self) 156 try: 157 self.channel.request("initialize", {"adapterID": "debugpy"}) 158 self.channel.request("attach", {"subProcessId": self.pid}) 159 self.channel.request("configurationDone") 160 self.channel.request("disconnect") 161 except Exception: 162 log.swallow_exception("Failed to unblock orphaned subprocess:") 163 self.channel.close() 164 165 def __str__(self): 166 return "Server" + ("[?]" if self.pid is None else f"[pid={self.pid}]") 167 168 def authenticate(self): 169 if access_token is None and adapter.access_token is None: 170 return 171 auth = self.channel.request( 172 "pydevdAuthorize", {"debugServerAccessToken": access_token} 173 ) 174 if auth["clientAccessToken"] != adapter.access_token: 175 self.channel.close() 176 raise RuntimeError('Mismatched "clientAccessToken"; server not authorized.') 177 178 def request(self, request): 179 raise request.isnt_valid( 180 "Requests from the debug server to the client are not allowed." 181 ) 182 183 def event(self, event): 184 pass 185 186 def terminated_event(self, event): 187 self.channel.close() 188 189 def disconnect(self): 190 with _lock: 191 self.disconnected = True 192 if self.server is not None: 193 # If the disconnect happened while Server was being instantiated, 194 # we need to tell it, so that it can clean up via Session.finalize(). 195 # It will also take care of deregistering the connection in that case. 196 self.server.disconnect() 197 elif self in _connections: 198 _connections.remove(self) 199 _connections_changed.set() 200 201 def attach_to_session(self, session): 202 """Attaches this server to the specified Session as a Server component. 203 204 Raises ValueError if the server already belongs to some session. 205 """ 206 207 with _lock: 208 if self.server is not None: 209 raise ValueError 210 log.info("Attaching {0} to {1}", self, session) 211 self.server = Server(session, self) 212 213 214 class Server(components.Component): 215 """Handles the debug server side of a debug session.""" 216 217 message_handler = components.Component.message_handler 218 219 connection: Connection 220 221 class Capabilities(components.Capabilities): 222 PROPERTIES = { 223 "supportsCompletionsRequest": False, 224 "supportsConditionalBreakpoints": False, 225 "supportsConfigurationDoneRequest": False, 226 "supportsDataBreakpoints": False, 227 "supportsDelayedStackTraceLoading": False, 228 "supportsDisassembleRequest": False, 229 "supportsEvaluateForHovers": False, 230 "supportsExceptionInfoRequest": False, 231 "supportsExceptionOptions": False, 232 "supportsFunctionBreakpoints": False, 233 "supportsGotoTargetsRequest": False, 234 "supportsHitConditionalBreakpoints": False, 235 "supportsLoadedSourcesRequest": False, 236 "supportsLogPoints": False, 237 "supportsModulesRequest": False, 238 "supportsReadMemoryRequest": False, 239 "supportsRestartFrame": False, 240 "supportsRestartRequest": False, 241 "supportsSetExpression": False, 242 "supportsSetVariable": False, 243 "supportsStepBack": False, 244 "supportsStepInTargetsRequest": False, 245 "supportsTerminateRequest": True, 246 "supportsTerminateThreadsRequest": False, 247 "supportsValueFormattingOptions": False, 248 "exceptionBreakpointFilters": [], 249 "additionalModuleColumns": [], 250 "supportedChecksumAlgorithms": [], 251 } 252 253 def __init__(self, session, connection): 254 assert connection.server is None 255 with session: 256 assert not session.server 257 super().__init__(session, channel=connection.channel) 258 259 self.connection = connection 260 261 assert self.session.pid is None 262 if self.session.launcher and self.session.launcher.pid != self.pid: 263 log.info( 264 "Launcher reported PID={0}, but server reported PID={1}", 265 self.session.launcher.pid, 266 self.pid, 267 ) 268 self.session.pid = self.pid 269 270 session.server = self 271 272 @property 273 def pid(self): 274 """Process ID of the debuggee process, as reported by the server.""" 275 return self.connection.pid 276 277 @property 278 def ppid(self): 279 """Parent process ID of the debuggee process, as reported by the server.""" 280 return self.connection.ppid 281 282 def initialize(self, request): 283 assert request.is_request("initialize") 284 self.connection.authenticate() 285 request = self.channel.propagate(request) 286 request.wait_for_response() 287 self.capabilities = self.Capabilities(self, request.response) 288 289 # Generic request handler, used if there's no specific handler below. 290 @message_handler 291 def request(self, request): 292 # Do not delegate requests from the server by default. There is a security 293 # boundary between the server and the adapter, and we cannot trust arbitrary 294 # requests sent over that boundary, since they may contain arbitrary code 295 # that the client will execute - e.g. "runInTerminal". The adapter must only 296 # propagate requests that it knows are safe. 297 raise request.isnt_valid( 298 "Requests from the debug server to the client are not allowed." 299 ) 300 301 # Generic event handler, used if there's no specific handler below. 302 @message_handler 303 def event(self, event): 304 self.client.propagate_after_start(event) 305 306 @message_handler 307 def initialized_event(self, event): 308 # pydevd doesn't send it, but the adapter will send its own in any case. 309 pass 310 311 @message_handler 312 def process_event(self, event): 313 # If there is a launcher, it's handling the process event. 314 if not self.launcher: 315 self.client.propagate_after_start(event) 316 317 @message_handler 318 def continued_event(self, event): 319 # https://github.com/microsoft/ptvsd/issues/1530 320 # 321 # DAP specification says that a step request implies that only the thread on 322 # which that step occurred is resumed for the duration of the step. However, 323 # for VS compatibility, pydevd can operate in a mode that resumes all threads 324 # instead. This is set according to the value of "steppingResumesAllThreads" 325 # in "launch" or "attach" request, which defaults to true. If explicitly set 326 # to false, pydevd will only resume the thread that was stepping. 327 # 328 # To ensure that the client is aware that other threads are getting resumed in 329 # that mode, pydevd sends a "continued" event with "allThreadsResumed": true. 330 # when responding to a step request. This ensures correct behavior in VSCode 331 # and other DAP-conformant clients. 332 # 333 # On the other hand, VS does not follow the DAP specification in this regard. 334 # When it requests a step, it assumes that all threads will be resumed, and 335 # does not expect to see "continued" events explicitly reflecting that fact. 336 # If such events are sent regardless, VS behaves erratically. Thus, we have 337 # to suppress them specifically for VS. 338 if self.client.client_id not in ("visualstudio", "vsformac"): 339 self.client.propagate_after_start(event) 340 341 @message_handler 342 def exited_event(self, event: messaging.Event): 343 if event("pydevdReason", str, optional=True) == "processReplaced": 344 # The parent process used some API like exec() that replaced it with another 345 # process in situ. The connection will shut down immediately afterwards, but 346 # we need to keep the corresponding session alive long enough to report the 347 # subprocess to it. 348 self.connection.process_replaced = True 349 else: 350 # If there is a launcher, it's handling the exit code. 351 if not self.launcher: 352 self.client.propagate_after_start(event) 353 354 @message_handler 355 def terminated_event(self, event): 356 # Do not propagate this, since we'll report our own. 357 self.channel.close() 358 359 def detach_from_session(self): 360 with _lock: 361 self.is_connected = False 362 self.channel.handlers = self.connection 363 self.channel.name = self.channel.stream.name = str(self.connection) 364 self.connection.server = None 365 366 def disconnect(self): 367 if self.connection.process_replaced: 368 # Wait for the replacement server to connect to the adapter, and to report 369 # itself to the client for this session if there is one. 370 log.info("{0} is waiting for replacement subprocess.", self) 371 session = self.session 372 if not session.client or not session.client.is_connected: 373 wait_for_connection( 374 session, lambda conn: conn.pid == self.pid, timeout=60 375 ) 376 else: 377 self.wait_for( 378 lambda: ( 379 not session.client 380 or not session.client.is_connected 381 or any( 382 conn.pid == self.pid 383 for conn in session.client.known_subprocesses 384 ) 385 ), 386 timeout=60, 387 ) 388 with _lock: 389 _connections.remove(self.connection) 390 _connections_changed.set() 391 super().disconnect() 392 393 394 def serve(host="127.0.0.1", port=0): 395 global listener 396 listener = sockets.serve("Server", Connection, host, port) 397 sessions.report_sockets() 398 return listener.getsockname() 399 400 401 def is_serving(): 402 return listener is not None 403 404 405 def stop_serving(): 406 global listener 407 try: 408 if listener is not None: 409 listener.close() 410 listener = None 411 except Exception: 412 log.swallow_exception(level="warning") 413 sessions.report_sockets() 414 415 416 def connections(): 417 with _lock: 418 return list(_connections) 419 420 421 def wait_for_connection(session, predicate, timeout=None): 422 """Waits until there is a server matching the specified predicate connected to 423 this adapter, and returns the corresponding Connection. 424 425 If there is more than one server connection already available, returns the oldest 426 one. 427 """ 428 429 def wait_for_timeout(): 430 time.sleep(timeout) 431 wait_for_timeout.timed_out = True 432 with _lock: 433 _connections_changed.set() 434 435 wait_for_timeout.timed_out = timeout == 0 436 if timeout: 437 thread = threading.Thread( 438 target=wait_for_timeout, name="servers.wait_for_connection() timeout" 439 ) 440 thread.daemon = True 441 thread.start() 442 443 if timeout != 0: 444 log.info("{0} waiting for connection from debug server...", session) 445 while True: 446 with _lock: 447 _connections_changed.clear() 448 conns = (conn for conn in _connections if predicate(conn)) 449 conn = next(conns, None) 450 if conn is not None or wait_for_timeout.timed_out: 451 return conn 452 _connections_changed.wait() 453 454 455 def wait_until_disconnected(): 456 """Blocks until all debug servers disconnect from the adapter. 457 458 If there are no server connections, waits until at least one is established first, 459 before waiting for it to disconnect. 460 """ 461 while True: 462 _connections_changed.wait() 463 with _lock: 464 _connections_changed.clear() 465 if not len(_connections): 466 return 467 468 469 def dont_wait_for_first_connection(): 470 """Unblocks any pending wait_until_disconnected() call that is waiting on the 471 first server to connect. 472 """ 473 with _lock: 474 _connections_changed.set() 475 476 477 def inject(pid, debugpy_args, on_output): 478 host, port = listener.getsockname() 479 480 cmdline = [ 481 sys.executable, 482 os.path.dirname(debugpy.__file__), 483 "--connect", 484 host + ":" + str(port), 485 ] 486 if adapter.access_token is not None: 487 cmdline += ["--adapter-access-token", adapter.access_token] 488 cmdline += debugpy_args 489 cmdline += ["--pid", str(pid)] 490 491 log.info("Spawning attach-to-PID debugger injector: {0!r}", cmdline) 492 try: 493 injector = subprocess.Popen( 494 cmdline, 495 bufsize=0, 496 stdin=subprocess.PIPE, 497 stdout=subprocess.PIPE, 498 stderr=subprocess.STDOUT, 499 ) 500 except Exception as exc: 501 log.swallow_exception( 502 "Failed to inject debug server into process with PID={0}", pid 503 ) 504 raise messaging.MessageHandlingError( 505 "Failed to inject debug server into process with PID={0}: {1}".format( 506 pid, exc 507 ) 508 ) 509 510 # We need to capture the output of the injector - needed so that it doesn't 511 # get blocked on a write() syscall (besides showing it to the user if it 512 # is taking longer than expected). 513 514 output_collected = [] 515 output_collected.append("--- Starting attach to pid: {0} ---\n".format(pid)) 516 517 def capture(stream): 518 nonlocal output_collected 519 try: 520 while True: 521 line = stream.readline() 522 if not line: 523 break 524 line = line.decode("utf-8", "replace") 525 output_collected.append(line) 526 log.info("Injector[PID={0}] output: {1}", pid, line.rstrip()) 527 log.info("Injector[PID={0}] exited.", pid) 528 except Exception: 529 s = io.StringIO() 530 traceback.print_exc(file=s) 531 on_output("stderr", s.getvalue()) 532 533 threading.Thread( 534 target=capture, 535 name=f"Injector[PID={pid}] stdout", 536 args=(injector.stdout,), 537 daemon=True, 538 ).start() 539 540 def info_on_timeout(): 541 nonlocal output_collected 542 taking_longer_than_expected = False 543 initial_time = time.time() 544 while True: 545 time.sleep(1) 546 returncode = injector.poll() 547 if returncode is not None: 548 if returncode != 0: 549 # Something didn't work out. Let's print more info to the user. 550 on_output( 551 "stderr", 552 "Attach to PID failed.\n\n", 553 ) 554 555 old = output_collected 556 output_collected = [] 557 contents = "".join(old) 558 on_output("stderr", "".join(contents)) 559 break 560 561 elapsed = time.time() - initial_time 562 on_output( 563 "stdout", "Attaching to PID: %s (elapsed: %.2fs).\n" % (pid, elapsed) 564 ) 565 566 if not taking_longer_than_expected: 567 if elapsed > 10: 568 taking_longer_than_expected = True 569 if sys.platform in ("linux", "linux2"): 570 on_output( 571 "stdout", 572 "\nThe attach to PID is taking longer than expected.\n", 573 ) 574 on_output( 575 "stdout", 576 "On Linux it's possible to customize the value of\n", 577 ) 578 on_output( 579 "stdout", 580 "`PYDEVD_GDB_SCAN_SHARED_LIBRARIES` so that fewer libraries.\n", 581 ) 582 on_output( 583 "stdout", 584 "are scanned when searching for the needed symbols.\n\n", 585 ) 586 on_output( 587 "stdout", 588 "i.e.: set in your environment variables (and restart your editor/client\n", 589 ) 590 on_output( 591 "stdout", 592 "so that it picks up the updated environment variable value):\n\n", 593 ) 594 on_output( 595 "stdout", 596 "PYDEVD_GDB_SCAN_SHARED_LIBRARIES=libdl, libltdl, libc, libfreebl3\n\n", 597 ) 598 on_output( 599 "stdout", 600 "-- the actual library may be different (the gdb output typically\n", 601 ) 602 on_output( 603 "stdout", 604 "-- writes the libraries that will be used, so, it should be possible\n", 605 ) 606 on_output( 607 "stdout", 608 "-- to test other libraries if the above doesn't work).\n\n", 609 ) 610 if taking_longer_than_expected: 611 # If taking longer than expected, start showing the actual output to the user. 612 old = output_collected 613 output_collected = [] 614 contents = "".join(old) 615 if contents: 616 on_output("stderr", contents) 617 618 threading.Thread( 619 target=info_on_timeout, name=f"Injector[PID={pid}] info on timeout", daemon=True 620 ).start() 621