diff --git a/src/debugpy/_vendored/pydevd/pydevd_attach_to_process/add_code_to_python_process.py b/src/debugpy/_vendored/pydevd/pydevd_attach_to_process/add_code_to_python_process.py index ed43e3706..85f3353b1 100644 --- a/src/debugpy/_vendored/pydevd/pydevd_attach_to_process/add_code_to_python_process.py +++ b/src/debugpy/_vendored/pydevd/pydevd_attach_to_process/add_code_to_python_process.py @@ -261,7 +261,13 @@ def get_target_filename(is_target_process_64=None, prefix=None, extension=None): def run_python_code_windows(pid, python_code, connect_debugger_tracing=False, show_debug_info=0): assert '\'' not in python_code, 'Having a single quote messes with our command.' - from winappdbg.process import Process + + # Suppress winappdbg warning about sql package missing. + import warnings + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=ImportWarning) + from winappdbg.process import Process + if not isinstance(python_code, bytes): python_code = python_code.encode('utf-8') diff --git a/src/debugpy/adapter/clients.py b/src/debugpy/adapter/clients.py index ada460edf..ee1d15145 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__}, }, ) + sessions.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,24 @@ 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 +771,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..ca87483f8 100644 --- a/src/debugpy/adapter/sessions.py +++ b/src/debugpy/adapter/sessions.py @@ -282,3 +282,12 @@ def wait_until_ended(): return _sessions_changed.clear() _sessions_changed.wait() + + +def report_sockets(): + if not _sessions: + return + session = sorted(_sessions, key=lambda session: session.id)[0] + client = session.client + if client is not None: + client.report_sockets() diff --git a/tests/debug/config.py b/tests/debug/config.py index c6ec3035d..90115ef8e 100644 --- a/tests/debug/config.py +++ b/tests/debug/config.py @@ -125,6 +125,9 @@ def __setitem__(self, key, value): assert key in self.PROPERTIES self._dict[key] = value + def __repr__(self): + return repr(dict(self)) + def __getstate__(self): return dict(self) diff --git a/tests/debug/runners.py b/tests/debug/runners.py index 71f985f75..dc60d0aea 100644 --- a/tests/debug/runners.py +++ b/tests/debug/runners.py @@ -199,6 +199,7 @@ def attach_pid(session, target, cwd=None, wait=True): config["processId"] = session.debuggee.pid session.spawn_adapter() + session.expect_server_socket() with session.request_attach(): yield @@ -260,6 +261,10 @@ def attach_connect(session, target, method, cwd=None, wait=True, log_dir=None): except KeyError: pass + # If adapter is connecting to the client, the server is already started, + # so it should be reported in the initial event. + session.expect_server_socket() + session.spawn_debuggee(args, cwd=cwd, setup=debuggee_setup) session.wait_for_adapter_socket() session.connect_to_adapter((host, port)) diff --git a/tests/debug/session.py b/tests/debug/session.py index 21caeebaa..d09219568 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. @@ -128,6 +133,10 @@ def __init__(self, debug_config=None): self.scratchpad = comms.ScratchPad(self) """The ScratchPad object to talk to the debuggee.""" + self.start_command = None + """Set to either "launch" or "attach" just before the corresponding request is sent. + """ + self.start_request = None """The "launch" or "attach" request that started executing code in this session. """ @@ -183,6 +192,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"})), @@ -296,6 +306,10 @@ def __exit__(self, exc_type, exc_val, exc_tb): @property def ignore_unobserved(self): return self.timeline.ignore_unobserved + + @property + def is_subprocess(self): + return "subProcessId" in self.config def open_backchannel(self): assert self.backchannel is None @@ -352,7 +366,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 +422,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( @@ -430,12 +448,22 @@ def spawn_adapter(self, args=()): stream = messaging.JsonIOStream.from_process(self.adapter, name=self.adapter_id) self._start_channel(stream) + def expect_server_socket(self, port=some.int): + self.expected_adapter_sockets["server"] = { + "host": some.str, + "port": port, + "internal": True, + } + def connect_to_adapter(self, address): assert self.channel is None 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 + sock = sockets.create_client() sock.connect(address) @@ -470,8 +498,12 @@ def send_request(self, command, arguments=None, proceed=True): if self.timeline.is_frozen and proceed: self.proceed() + if command in ("launch", "attach"): + self.start_command = command + message = self.channel.send_request(command, arguments) request = self.timeline.record_request(message) + if command in ("launch", "attach"): self.start_request = request @@ -483,16 +515,52 @@ 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": + assert not self.is_subprocess + 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_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 +582,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") @@ -567,6 +637,9 @@ def _start_channel(self, stream): ) ) + if not self.is_subprocess: + self.wait_for_next(timeline.Event("debugpySockets")) + self.request("initialize", self.capabilities) def all_events(self, event, body=some.object): @@ -632,9 +705,20 @@ 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.expect_server_socket() + return self._request_start("launch") def request_attach(self): + # In attach(listen) scenario, adapter only starts listening for server + # after receiving the "attach" request. + listen = self.config.get("listen", None) + if listen is not None: + assert "server" not in self.expected_adapter_sockets + self.expect_server_socket(listen["port"]) return self._request_start("attach") def request_continue(self): @@ -787,7 +871,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 diff --git a/tests/debugpy/test_attach.py b/tests/debugpy/test_attach.py index 7d635dbce..afabc1acf 100644 --- a/tests/debugpy/test_attach.py +++ b/tests/debugpy/test_attach.py @@ -72,6 +72,7 @@ def code_to_debug(): ) session.wait_for_adapter_socket() + session.expect_server_socket() session.connect_to_adapter((host, port)) with session.request_attach(): pass @@ -124,13 +125,14 @@ def code_to_debug(): session1.expected_exit_code = None # not expected to exit on disconnect with run(session1, target(code_to_debug)): - pass + expected_adapter_sockets = session1.expected_adapter_sockets.copy() session1.wait_for_stop(expected_frames=[some.dap.frame(code_to_debug, "first")]) session1.disconnect() with debug.Session() as session2: session2.config.update(session1.config) + session2.expected_adapter_sockets = expected_adapter_sockets if "connect" in session2.config: session2.connect_to_adapter( (session2.config["connect"]["host"], session2.config["connect"]["port"])