Add parent-session-pid argument (#1920)

* Add parent-session-pid argument

Add the ability to specify the parent process id when connecting a new
DAP server to the client. This value is used instead of the actual
process' parent id so that it can be associated with a specific debug
session even if it hasn't been spawned directly by that parent process.

* Add tests for new option
This commit is contained in:
Jordan Borean 2025-07-08 02:52:53 +10:00 committed by GitHub
parent 0d65353cc6
commit b387710b7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 106 additions and 13 deletions

View file

@ -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

View file

@ -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.

View file

@ -293,9 +293,9 @@ listen.called = 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:

View file

@ -34,6 +34,7 @@ Usage: debugpy --listen | --connect
[--wait-for-client]
[--configure-<name> <value>]...
[--log-to <path>] [--log-to-stderr]
[--parent-session-pid <pid>]]
{1}
[<arg>]...
""".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 @@ switches = [
("--connect", "<address>", set_address("connect")),
("--wait-for-client", None, set_const("wait_for_client", True)),
("--configure-.+", "<value>", set_config),
("--parent-session-pid", "<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", "<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))

View file

@ -45,6 +45,7 @@ def cli(pyfile):
"target",
"target_kind",
"wait_for_client",
"parent_session_pid",
]
}
@ -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)

View file

@ -596,3 +596,70 @@ def test_subprocess_replace(pyfile, target, run):
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()