Format: text/x-python
⬇ Download File (23.5 KB)
   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