diff --git a/src/debugpy/adapter/clients.py b/src/debugpy/adapter/clients.py index ada460edf..43b47d091 100644 --- a/src/debugpy/adapter/clients.py +++ b/src/debugpy/adapter/clients.py @@ -11,7 +11,7 @@ import debugpy from debugpy import adapter, common, launcher from debugpy.common import json, log, messaging, sockets -from debugpy.adapter import components, servers, sessions +from debugpy.adapter import clients, components, launchers, servers, sessions class Client(components.Component): @@ -110,6 +110,7 @@ def __init__(self, sock): "data": {"packageVersion": debugpy.__version__}, }, ) + self.report_sockets() def propagate_after_start(self, event): # pydevd starts sending events as soon as we connect, but the client doesn't @@ -701,6 +702,25 @@ def disconnect_request(self, request): def disconnect(self): super().disconnect() + def report_sockets(self): + sockets = [ + { + "host": host, + "port": port, + "internal": listener is not clients.listener, + } + for listener in [clients.listener, launchers.listener, servers.listener] + if listener is not None + for (host, port) in [listener.getsockname()] + ] + + self.channel.send_event( + "debugpySockets", + { + "sockets": sockets + }, + ) + def notify_of_subprocess(self, conn): log.info("{1} is a subprocess of {0}.", self, conn) with self.session: @@ -752,11 +772,16 @@ def notify_of_subprocess(self, conn): def serve(host, port): global listener listener = sockets.serve("Client", Client, host, port) + sessions.report_sockets() return listener.getsockname() def stop_serving(): - try: - listener.close() - except Exception: - log.swallow_exception(level="warning") + global listener + if listener is not None: + try: + listener.close() + except Exception: + log.swallow_exception(level="warning") + listener = None + sessions.report_sockets() diff --git a/src/debugpy/adapter/launchers.py b/src/debugpy/adapter/launchers.py index 444e54dd9..38a990d76 100644 --- a/src/debugpy/adapter/launchers.py +++ b/src/debugpy/adapter/launchers.py @@ -8,7 +8,9 @@ from debugpy import adapter, common from debugpy.common import log, messaging, sockets -from debugpy.adapter import components, servers +from debugpy.adapter import components, servers, sessions + +listener = None class Launcher(components.Component): @@ -76,6 +78,8 @@ def spawn_debuggee( console_title, sudo, ): + global listener + # -E tells sudo to propagate environment variables to the target process - this # is necessary for launcher to get DEBUGPY_LAUNCHER_PORT and DEBUGPY_LOG_DIR. cmdline = ["sudo", "-E"] if sudo else [] @@ -101,6 +105,7 @@ def on_launcher_connected(sock): raise start_request.cant_handle( "{0} couldn't create listener socket for launcher: {1}", session, exc ) + sessions.report_sockets() try: launcher_host, launcher_port = listener.getsockname() @@ -189,3 +194,5 @@ def on_launcher_connected(sock): finally: listener.close() + listener = None + sessions.report_sockets() diff --git a/src/debugpy/adapter/servers.py b/src/debugpy/adapter/servers.py index 47f684a04..025823616 100644 --- a/src/debugpy/adapter/servers.py +++ b/src/debugpy/adapter/servers.py @@ -13,7 +13,7 @@ import debugpy from debugpy import adapter from debugpy.common import json, log, messaging, sockets -from debugpy.adapter import components +from debugpy.adapter import components, sessions import traceback import io @@ -394,6 +394,7 @@ def disconnect(self): def serve(host="127.0.0.1", port=0): global listener listener = sockets.serve("Server", Connection, host, port) + sessions.report_sockets() return listener.getsockname() @@ -409,6 +410,7 @@ def stop_serving(): listener = None except Exception: log.swallow_exception(level="warning") + sessions.report_sockets() def connections(): diff --git a/src/debugpy/adapter/sessions.py b/src/debugpy/adapter/sessions.py index 0abebcc8c..873617b2c 100644 --- a/src/debugpy/adapter/sessions.py +++ b/src/debugpy/adapter/sessions.py @@ -282,3 +282,10 @@ def wait_until_ended(): return _sessions_changed.clear() _sessions_changed.wait() + + +def report_sockets(): + for session in _sessions: + client = session.client + if client is not None: + client.report_sockets() diff --git a/tests/debug/session.py b/tests/debug/session.py index 21caeebaa..e9e0670e5 100644 --- a/tests/debug/session.py +++ b/tests/debug/session.py @@ -102,6 +102,11 @@ def __init__(self, debug_config=None): self.adapter = None """psutil.Popen instance for the adapter process.""" + self.expected_adapter_sockets = { + "client": {"host": some.str, "port": some.int, "internal": False}, + } + """The sockets which the adapter is expected to report.""" + self.adapter_endpoints = None """Name of the file that contains the adapter endpoints information. @@ -183,6 +188,7 @@ def __init__(self, debug_config=None): timeline.Event("module"), timeline.Event("continued"), timeline.Event("debugpyWaitingForServer"), + timeline.Event("debugpySockets"), timeline.Event("thread", some.dict.containing({"reason": "started"})), timeline.Event("thread", some.dict.containing({"reason": "exited"})), timeline.Event("output", some.dict.containing({"category": "stdout"})), @@ -352,7 +358,9 @@ def _make_env(self, base_env, codecov=True): return env def _make_python_cmdline(self, exe, *args): - return [str(s.strpath if isinstance(s, py.path.local) else s) for s in [exe, *args]] + return [ + str(s.strpath if isinstance(s, py.path.local) else s) for s in [exe, *args] + ] def spawn_debuggee(self, args, cwd=None, exe=sys.executable, setup=None): assert self.debuggee is None @@ -406,7 +414,9 @@ def spawn_adapter(self, args=()): assert self.adapter is None assert self.channel is None - args = self._make_python_cmdline(sys.executable, os.path.dirname(debugpy.adapter.__file__), *args) + args = self._make_python_cmdline( + sys.executable, os.path.dirname(debugpy.adapter.__file__), *args + ) env = self._make_env(self.spawn_adapter.env) log.info( @@ -436,6 +446,16 @@ def connect_to_adapter(self, address): self.before_connect(address) host, port = address log.info("Connecting to {0} at {1}:{2}", self.adapter_id, host, port) + + self.expected_adapter_sockets["client"]["port"] = port + + # If we're attaching, the server is already started, so it should be reported. + self.expected_adapter_sockets["server"] = { + "host": some.str, + "port": some.str, + "internal": True, + } + sock = sockets.create_client() sock.connect(address) @@ -483,16 +503,54 @@ def send_request(self, command, arguments=None, proceed=True): def _process_event(self, event): occ = self.timeline.record_event(event, block=False) + if event.event == "exited": self.observe(occ) self.exit_code = event("exitCode", int) self.exit_reason = event("reason", str, optional=True) assert self.exit_code == self.expected_exit_code + + elif event.event == "terminated": + # Server socket should be closed next. + self.expected_adapter_sockets.pop("server", None) + elif event.event == "debugpyAttach": self.observe(occ) pid = event("subProcessId", int) watchdog.register_spawn(pid, f"{self.debuggee_id}-subprocess-{pid}") + elif event.event == "debugpySockets": + sockets = list(event("sockets", json.array(json.object()))) + for purpose, expected_socket in self.expected_adapter_sockets.items(): + if expected_socket is None: + continue + socket = None + for socket in sockets: + if socket == expected_socket: + break + assert ( + socket is not None + ), f"Expected {purpose} socket {expected_socket} not reported by adapter" + sockets.remove(socket) + assert not sockets, f"Unexpected sockets reported by adapter: {sockets}" + + if ( + self.start_request is not None + and self.start_request.command == "launch" + ): + if "launcher" in self.expected_adapter_sockets: + # If adapter has just reported the launcher socket, it shouldn't be + # reported thereafter. + self.expected_adapter_sockets["launcher"] = None + elif "server" in self.expected_adapter_sockets: + # If adapter just reported the server socket, the next event should + # report the launcher socket. + self.expected_adapter_sockets["launcher"] = { + "host": some.str, + "port": some.int, + "internal": False, + } + def run_in_terminal(self, args, cwd, env): exe = args.pop(0) self.spawn_debuggee.env.update(env) @@ -514,10 +572,12 @@ def _process_request(self, request): except Exception as exc: log.swallow_exception('"runInTerminal" failed:') raise request.cant_handle(str(exc)) + elif request.command == "startDebugging": pid = request("configuration", dict)("subProcessId", int) watchdog.register_spawn(pid, f"{self.debuggee_id}-subprocess-{pid}") return {} + else: raise request.isnt_valid("not supported") @@ -549,7 +609,8 @@ def _start_channel(self, stream): self.channel.start() self.wait_for_next( - timeline.Event( + timeline.Event("debugpySockets") + & timeline.Event( "output", { "category": "telemetry", @@ -632,6 +693,15 @@ def request_launch(self): # If specified, launcher will use it in lieu of PYTHONPATH it inherited # from the adapter when spawning debuggee, so we need to adjust again. self.config.env.prepend_to("PYTHONPATH", DEBUGGEE_PYTHONPATH.strpath) + + # Adapter is going to start listening for server and spawn the launcher at + # this point. Server socket gets reported first. + self.expected_adapter_sockets["server"] = { + "host": some.str, + "port": some.int, + "internal": True, + } + return self._request_start("launch") def request_attach(self): @@ -787,7 +857,9 @@ def wait_for_stop( return StopInfo(stopped, frames, tid, fid) def wait_for_next_subprocess(self): - message = self.timeline.wait_for_next(timeline.Event("debugpyAttach") | timeline.Request("startDebugging")) + message = self.timeline.wait_for_next( + timeline.Event("debugpyAttach") | timeline.Request("startDebugging") + ) if isinstance(message, timeline.EventOccurrence): config = message.body assert "request" in config