Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/debugpy/_vendored/pydevd/pydevd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -3034,6 +3040,7 @@ def settrace(
client_access_token,
__setup_holder__=__setup_holder__,
notify_stdin=notify_stdin,
ppid=ppid,
)


Expand All @@ -3057,6 +3064,7 @@ def _locked_settrace(
client_access_token,
__setup_holder__,
notify_stdin,
ppid,
):
if patch_multiprocessing:
try:
Expand Down Expand Up @@ -3088,6 +3096,7 @@ def _locked_settrace(
"port": int(port),
"multiprocess": patch_multiprocessing,
"skip-notify-stdin": not notify_stdin,
pydevd_constants.ARGUMENT_PPID: ppid,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this only be set if ppid is not zero?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wait, no it looks like it would get picked up here if the argument is zero:

    def on_pydevdsysteminfo_request(self, py_db, request):
        try:
            pid = os.getpid()
        except AttributeError:
            pid = None

        # It's possible to have the ppid reported from args. In this case, use that instead of the
        # real ppid (athough we're using `ppid`, what we want in meaning is the `launcher_pid` --
        # so, if a python process is launched from another python process, consider that process the
        # parent and not any intermediary stubs).

        ppid = py_db.get_arg_ppid() or self.api.get_ppid() <== This should ignore it if zero

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, the pydevd CLI sets ppid to a default of 0, this just replicates that same functionality but through the settrace API call.

}
SetupHolder.setup = setup

Expand Down
6 changes: 5 additions & 1 deletion src/debugpy/public_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/debugpy/server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion src/debugpy/server/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
[--wait-for-client]
[--configure-<name> <value>]...
[--log-to <path>] [--log-to-stderr]
[--parent-session-pid <pid>]]
{1}
[<arg>]...
""".format(
Expand All @@ -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()
Expand Down Expand Up @@ -179,6 +181,7 @@ def do(arg, it):
("--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")),
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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))

Expand Down
24 changes: 16 additions & 8 deletions tests/debugpy/server/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def cli_parser():
"target",
"target_kind",
"wait_for_client",
"parent_session_pid",
]
}

Expand All @@ -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:
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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)
69 changes: 68 additions & 1 deletion tests/debugpy/test_multiproc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Loading