diff --git a/src/debugpy/_vendored/pydevd/pydevd.py b/src/debugpy/_vendored/pydevd/pydevd.py index bc13f111..13ee67d5 100644 --- a/src/debugpy/_vendored/pydevd/pydevd.py +++ b/src/debugpy/_vendored/pydevd/pydevd.py @@ -2947,6 +2947,7 @@ def settrace( client_access_token=None, notify_stdin=True, protocol=None, + ppid=0, **kwargs, ): """Sets the tracing function with the pydev debug function and initializes needed facilities. @@ -3006,6 +3007,11 @@ def settrace( When using in Eclipse the protocol should not be passed, but when used in VSCode or some other IDE/editor that accepts the Debug Adapter Protocol then 'dap' should be passed. + + :param ppid: + Override the parent process id (PPID) for the current debugging session. This PPID is + reported to the debug client (IDE) and can be used to act like a child process of an + existing debugged process without being a child process. """ if protocol and protocol.lower() == "dap": pydevd_defaults.PydevdCustomization.DEFAULT_PROTOCOL = pydevd_constants.HTTP_JSON_PROTOCOL @@ -3034,6 +3040,7 @@ def settrace( client_access_token, __setup_holder__=__setup_holder__, notify_stdin=notify_stdin, + ppid=ppid, ) @@ -3057,6 +3064,7 @@ def _locked_settrace( client_access_token, __setup_holder__, notify_stdin, + ppid, ): if patch_multiprocessing: try: @@ -3088,6 +3096,7 @@ def _locked_settrace( "port": int(port), "multiprocess": patch_multiprocessing, "skip-notify-stdin": not notify_stdin, + pydevd_constants.ARGUMENT_PPID: ppid, } SetupHolder.setup = setup diff --git a/src/debugpy/public_api.py b/src/debugpy/public_api.py index c61a2607..e490ea16 100644 --- a/src/debugpy/public_api.py +++ b/src/debugpy/public_api.py @@ -120,7 +120,7 @@ def listen( ... @_api() -def connect(__endpoint: Endpoint | int, *, access_token: str | None = None) -> Endpoint: +def connect(__endpoint: Endpoint | int, *, access_token: str | None = None, parent_session_pid: int | None = None) -> Endpoint: """Tells an existing debug adapter instance that is listening on the specified address to debug this process. @@ -131,6 +131,10 @@ def connect(__endpoint: Endpoint | int, *, access_token: str | None = None) -> E `access_token` must be the same value that was passed to the adapter via the `--server-access-token` command-line switch. + `parent_session_pid` is the PID of the parent session to associate + with. This is useful if running in a process that is not an immediate + child of the parent process being debugged. + This function does't wait for a client to connect to the debug adapter that it connects to. Use `wait_for_client` to block execution until the client connects. diff --git a/src/debugpy/server/api.py b/src/debugpy/server/api.py index eb54ffed..81356783 100644 --- a/src/debugpy/server/api.py +++ b/src/debugpy/server/api.py @@ -293,9 +293,9 @@ def listen(address, settrace_kwargs, in_process_debug_adapter=False): @_starts_debugging -def connect(address, settrace_kwargs, access_token=None): +def connect(address, settrace_kwargs, access_token=None, parent_session_pid=None): host, port = address - _settrace(host=host, port=port, client_access_token=access_token, **settrace_kwargs) + _settrace(host=host, port=port, client_access_token=access_token, ppid=parent_session_pid or 0, **settrace_kwargs) class wait_for_client: diff --git a/src/debugpy/server/cli.py b/src/debugpy/server/cli.py index a64f515a..9f569311 100644 --- a/src/debugpy/server/cli.py +++ b/src/debugpy/server/cli.py @@ -34,6 +34,7 @@ [--wait-for-client] [--configure- ]... [--log-to ] [--log-to-stderr] + [--parent-session-pid ]] {1} []... """.format( @@ -51,6 +52,7 @@ class Options(object): wait_for_client = False adapter_access_token = None config: Dict[str, Any] = {} + parent_session_pid: Union[int, None] = None options = Options() @@ -179,6 +181,7 @@ def do(arg, it): ("--connect", "
", set_address("connect")), ("--wait-for-client", None, set_const("wait_for_client", True)), ("--configure-.+", "", set_config), + ("--parent-session-pid", "", set_arg("parent_session_pid", lambda x: int(x) if x else None)), # Switches that are used internally by the client or debugpy itself. ("--adapter-access-token", "", set_arg("adapter_access_token")), @@ -230,6 +233,8 @@ def parse_args(): raise ValueError("either --listen or --connect is required") if options.adapter_access_token is not None and options.mode != "connect": raise ValueError("--adapter-access-token requires --connect") + if options.parent_session_pid is not None and options.mode != "connect": + raise ValueError("--parent-session-pid requires --connect") if options.target_kind == "pid" and options.wait_for_client: raise ValueError("--pid does not support --wait-for-client") @@ -321,7 +326,7 @@ def start_debugging(argv_0): if options.mode == "listen" and options.address is not None: debugpy.listen(options.address) elif options.mode == "connect" and options.address is not None: - debugpy.connect(options.address, access_token=options.adapter_access_token) + debugpy.connect(options.address, access_token=options.adapter_access_token, parent_session_pid=options.parent_session_pid) else: raise AssertionError(repr(options.mode)) diff --git a/tests/debugpy/server/test_cli.py b/tests/debugpy/server/test_cli.py index 9b64ee0e..45fed752 100644 --- a/tests/debugpy/server/test_cli.py +++ b/tests/debugpy/server/test_cli.py @@ -45,6 +45,7 @@ def cli_parser(): "target", "target_kind", "wait_for_client", + "parent_session_pid", ] } @@ -71,7 +72,7 @@ def parse(args): log.debug("Failed to deserialize output: {0}, Output was: {1!r}", e, output) raise except subprocess.CalledProcessError as exc: - log.debug("Process exited with code {0}. Output: {1!r}, Error: {2!r}", + log.debug("Process exited with code {0}. Output: {1!r}, Error: {2!r}", exc.returncode, exc.output, exc.stderr) raise pickle.loads(exc.output) except EOFError: @@ -163,20 +164,20 @@ def test_configure_subProcess_from_environment(cli, value): def test_unsupported_switch(cli): with pytest.raises(ValueError) as ex: cli(["--listen", "8888", "--xyz", "123", "spam.py"]) - + assert "unrecognized switch --xyz" in str(ex.value) def test_unsupported_switch_from_environment(cli): with pytest.raises(ValueError) as ex: with mock.patch.dict(os.environ, {"DEBUGPY_EXTRA_ARGV": "--xyz 123"}): cli(["--listen", "8888", "spam.py"]) - + assert "unrecognized switch --xyz" in str(ex.value) def test_unsupported_configure(cli): with pytest.raises(ValueError) as ex: cli(["--connect", "127.0.0.1:8888", "--configure-xyz", "123", "spam.py"]) - + assert "unknown property 'xyz'" in str(ex.value) def test_unsupported_configure_from_environment(cli): @@ -189,26 +190,26 @@ def test_unsupported_configure_from_environment(cli): def test_address_required(cli): with pytest.raises(ValueError) as ex: cli(["-m", "spam"]) - + assert "either --listen or --connect is required" in str(ex.value) def test_missing_target(cli): with pytest.raises(ValueError) as ex: cli(["--listen", "8888"]) - + assert "missing target" in str(ex.value) def test_duplicate_switch(cli): with pytest.raises(ValueError) as ex: cli(["--listen", "8888", "--listen", "9999", "spam.py"]) - + assert "duplicate switch on command line: --listen" in str(ex.value) def test_duplicate_switch_from_environment(cli): with pytest.raises(ValueError) as ex: with mock.patch.dict(os.environ, {"DEBUGPY_EXTRA_ARGV": "--listen 8888 --listen 9999"}): cli(["spam.py"]) - + assert "duplicate switch from environment: --listen" in str(ex.value) # Test that switches can be read from the environment @@ -240,3 +241,10 @@ def test_script_args(cli): assert argv == ["arg1", "arg2"] assert options["target"] == "spam.py" + +# Tests that --parent-session-pid fails with --listen +def test_script_parent_pid_with_listen_failure(cli): + with pytest.raises(ValueError) as ex: + cli(["--listen", "8888", "--parent-session-pid", "1234", "spam.py"]) + + assert "--parent-session-pid requires --connect" in str(ex.value) diff --git a/tests/debugpy/test_multiproc.py b/tests/debugpy/test_multiproc.py index 9a6e4b14..223bdb7b 100644 --- a/tests/debugpy/test_multiproc.py +++ b/tests/debugpy/test_multiproc.py @@ -203,7 +203,7 @@ def parent(): return expected_child_config = expected_subprocess_config(parent_session) - + if method == "startDebugging": subprocess_request = parent_session.timeline.wait_for_next(timeline.Request("startDebugging")) child_config = subprocess_request.arguments("configuration", dict) @@ -596,3 +596,70 @@ def parent(): child_pid = backchannel.receive() assert child_pid == child_config["subProcessId"] assert str(child_pid) in child_config["name"] + + +@pytest.mark.parametrize("run", runners.all_launch) +def test_subprocess_with_parent_pid(pyfile, target, run): + @pyfile + def child(): + import sys + + assert "debugpy" in sys.modules + + import debugpy + + assert debugpy # @bp + + @pyfile + def parent(): + import debuggee + import os + import subprocess + import sys + + from debugpy.server import cli as debugpy_cli + + debuggee.setup() + + # Running it through a shell is necessary to ensure the + # --parent-session-pid option is tested and the underlying + # Python subprocess can associate with this one's debug session. + if sys.platform == "win32": + argv = ["cmd.exe", "/c"] + else: + argv = ["/bin/sh", "-c"] + + host, port = debugpy_cli.options.address + access_token = debugpy_cli.options.adapter_access_token + + shell_args = [ + sys.executable, + "-m", + "debugpy", + "--connect", f"{host}:{port}", + "--parent-session-pid", str(os.getpid()), + "--adapter-access-token", access_token, + sys.argv[1], + ] + argv.append(" ".join(shell_args)) + + subprocess.check_call(argv, env=os.environ | {"DEBUGPY_RUNNING": "false"}) + + with debug.Session() as parent_session: + with run(parent_session, target(parent, args=[child])): + parent_session.set_breakpoints(child, all) + + with parent_session.wait_for_next_subprocess() as child_session: + expected_child_config = expected_subprocess_config(parent_session) + child_config = child_session.config + child_config.pop("isOutputRedirected", None) + assert child_config == expected_child_config + + with child_session.start(): + child_session.set_breakpoints(child, all) + + child_session.wait_for_stop( + "breakpoint", + expected_frames=[some.dap.frame(child, line="bp")], + ) + child_session.request_continue()