From d00cc12f45690217e81958496acbd34613910018 Mon Sep 17 00:00:00 2001 From: Philip DePetro Date: Fri, 31 Jul 2026 13:08:46 -0700 Subject: [PATCH] Add lldb attach-to-PID option for debugpy on Linux Add an option to prefer lldb (falling back to gdb) when attaching to a process by PID on Linux. debugpy's attach-by-PID flow injects the debug server into a target process. On Linux it has always shelled out to gdb; on macOS it uses lldb. In some environments lldb is the available/preferred injector on Linux. This adds an opt-in preference, surfaced as a VS Code launch configuration option "linuxAttachPreferLldb". The selector flows end to end: adapter/clients.py reads linuxAttachPreferLldb from the attach request and forwards --linux-attach-prefer-lldb to the injector subprocess; server/cli.py parses that switch and sets the PYDEVD_ATTACH_PREFER_LLDB environment variable; add_code_to_python_process.py reads it at call time in the new run_python_code_linux dispatcher, which uses lldb when it is preferred and present on PATH, and otherwise transparently falls back to gdb. Implementation notes: - run_python_code_linux_lldb mirrors the existing run_python_code_mac lldb invocation but resolves the Linux .so via get_target_filename() and reuses the shared linux_and_mac/lldb_prepare.py driver. No native code changes are needed: both paths call the same exported DoAttach entry point. - The PEP 768 sys.remote_exec fast path (Python 3.14+) runs first and is unaffected; the lldb preference only applies on the gdb/lldb fallback path. --- .../add_code_to_python_process.py | 61 +++++++- src/debugpy/adapter/clients.py | 5 + src/debugpy/server/cli.py | 11 ++ .../server/test_add_code_to_python_process.py | 145 ++++++++++++++++++ tests/debugpy/server/test_cli.py | 83 ++++++++++ 5 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 tests/debugpy/server/test_add_code_to_python_process.py 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 57c4a34b..7ba53eed 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 @@ -71,6 +71,7 @@ # nasm.asm&x:\nasm\nasm-2.07-win32\nasm-2.07\ndisasm.exe -b arch nasm import ctypes import os +import shutil import struct import subprocess import sys @@ -395,7 +396,7 @@ def _win_write_to_shared_named_memory(python_code, pid): CloseHandle(filemap) -def run_python_code_linux(pid, python_code, connect_debugger_tracing=False, show_debug_info=0): +def run_python_code_linux_gdb(pid, python_code, connect_debugger_tracing=False, show_debug_info=0): assert "'" not in python_code, "Having a single quote messes with our command." target_dll = get_target_filename() @@ -544,6 +545,64 @@ def run_python_code_mac(pid, python_code, connect_debugger_tracing=False, show_d subprocess.check_call(" ".join(cmd), shell=True, env=env) +def run_python_code_linux_lldb(pid, python_code, connect_debugger_tracing=False, show_debug_info=0): + assert "'" not in python_code, "Having a single quote messes with our command." + + target_dll = get_target_filename() + if not target_dll: + raise RuntimeError("Could not find .so for attach to process.") + + libdir = os.path.dirname(__file__) + lldb_prepare_file = find_helper_script(libdir, "lldb_prepare.py") + + # Note: we currently don't support debug builds + is_debug = 0 + # Note that the space in the beginning of each line in the multi-line is important! + cmd = [ + "lldb", + "--no-lldbinit", # Do not automatically parse any '.lldbinit' files. + "--script-language", + "Python", + ] + + cmd.extend( + [ + "-o 'process attach --pid %d'" % pid, + "-o 'command script import \"%s\"'" % (lldb_prepare_file,), + '-o \'load_lib_and_attach "%s" %s "%s" %s\'' % (target_dll, is_debug, python_code, show_debug_info), + ] + ) + + cmd.extend( + [ + "-o 'process detach'", + "-o 'script import os; os._exit(0)'", + ] + ) + + env = os.environ.copy() + # Remove the PYTHONPATH (if lldb has a builtin Python it could fail if we + # have the PYTHONPATH for a different python version or some forced encoding). + env.pop("PYTHONIOENCODING", None) + env.pop("PYTHONPATH", None) + print("Running: %s" % (" ".join(cmd))) + subprocess.check_call(" ".join(cmd), shell=True, env=env) + + +def run_python_code_linux(pid, python_code, connect_debugger_tracing=False, show_debug_info=0): + # On Linux gdb is used by default. lldb can be preferred via the + # PYDEVD_ATTACH_PREFER_LLDB environment variable (set from the "linuxAttachPreferLldb" + # launch configuration option). The variable is read here, at call time, so the + # choice doesn't depend on import ordering. When lldb is preferred but not available + # on the PATH, we transparently fall back to gdb. + prefer_lldb = os.environ.get("PYDEVD_ATTACH_PREFER_LLDB", "").strip().lower() in ("1", "true", "yes") + if prefer_lldb: + if shutil.which("lldb") is not None: + return run_python_code_linux_lldb(pid, python_code, connect_debugger_tracing, show_debug_info) + print("PYDEVD_ATTACH_PREFER_LLDB is set but 'lldb' was not found on the PATH; falling back to gdb.") + return run_python_code_linux_gdb(pid, python_code, connect_debugger_tracing, show_debug_info) + + if IS_WINDOWS: run_python_code = run_python_code_windows elif IS_MAC: diff --git a/src/debugpy/adapter/clients.py b/src/debugpy/adapter/clients.py index 4931b70c..d3314216 100644 --- a/src/debugpy/adapter/clients.py +++ b/src/debugpy/adapter/clients.py @@ -522,6 +522,11 @@ def attach_request(self, request): raise request.isnt_valid('"processId" must be parseable as int') debugpy_args = request("debugpyArgs", json.array(str)) + # On Linux, optionally prefer lldb (falling back to gdb) to inject into the + # target process. Forwarded to the injector as a debugpy CLI switch. + if request("linuxAttachPreferLldb", False): + debugpy_args = debugpy_args + ["--linux-attach-prefer-lldb"] + def on_output(category, output): self.channel.send_event( "output", diff --git a/src/debugpy/server/cli.py b/src/debugpy/server/cli.py index aa506e0c..a4d5713f 100644 --- a/src/debugpy/server/cli.py +++ b/src/debugpy/server/cli.py @@ -38,6 +38,7 @@ [--parent-session-pid ]] [--adapter-access-token ] [--disable-sys-remote-exec]] + [--linux-attach-prefer-lldb] {1} []... """.format( @@ -58,6 +59,9 @@ class Options(object): config: Dict[str, Any] = {} parent_session_pid: Union[int, None] = None disable_sys_remote_exec = False + # When attaching by PID on Linux, prefer lldb to inject into the target process, + # falling back to gdb if lldb is not available. gdb is used by default. + linux_attach_prefer_lldb = False options = Options() @@ -189,6 +193,7 @@ def do(arg, it): ("--parent-session-pid", "", set_arg("parent_session_pid", lambda x: int(x) if x else None)), ("--adapter-access-token", "", set_arg("adapter_access_token")), ("--disable-sys-remote-exec", None, set_const("disable_sys_remote_exec", True)), + ("--linux-attach-prefer-lldb", None, set_const("linux_attach_prefer_lldb", True)), # Targets. The "" entry corresponds to positional command line arguments, # i.e. the ones not preceded by any switch name. @@ -495,6 +500,12 @@ def attach_to_pid(): assert os.path.exists(pydevd_attach_to_process_path) sys.path.append(pydevd_attach_to_process_path) + # Propagate the lldb preference down to add_code_to_python_process, which reads + # PYDEVD_ATTACH_PREFER_LLDB at call time to decide whether to prefer lldb (falling + # back to gdb) when injecting on Linux. + if options.linux_attach_prefer_lldb: + os.environ["PYDEVD_ATTACH_PREFER_LLDB"] = "1" + try: import add_code_to_python_process # noqa diff --git a/tests/debugpy/server/test_add_code_to_python_process.py b/tests/debugpy/server/test_add_code_to_python_process.py new file mode 100644 index 00000000..d14a931c --- /dev/null +++ b/tests/debugpy/server/test_add_code_to_python_process.py @@ -0,0 +1,145 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +"""Tests for the injector selection logic in pydevd's add_code_to_python_process. + +These cover the Linux gdb/lldb dispatcher and the lldb command line that it builds. +Neither gdb nor lldb is actually spawned, so they run on any platform. +""" + +import importlib.util +import os +import pytest + +from unittest import mock + +import debugpy + + +ATTACH_TO_PROCESS_DIR = os.path.join( + os.path.dirname(os.path.abspath(debugpy.__file__)), + "_vendored", + "pydevd", + "pydevd_attach_to_process", +) + + +@pytest.fixture(scope="module") +def acpp(): + """add_code_to_python_process, loaded by path. + + The module is not part of any package - debugpy itself imports it by appending + pydevd_attach_to_process to sys.path - so it is loaded here under a private name + to avoid clashing with that import. + """ + path = os.path.join(ATTACH_TO_PROCESS_DIR, "add_code_to_python_process.py") + spec = importlib.util.spec_from_file_location( + "add_code_to_python_process_under_test", path + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def injectors(acpp): + """Replaces both leaf injectors with mocks, and yields them as (gdb, lldb).""" + with mock.patch.object(acpp, "run_python_code_linux_gdb") as gdb: + with mock.patch.object(acpp, "run_python_code_linux_lldb") as lldb: + yield gdb, lldb + + +# gdb is the default; lldb is opt-in via PYDEVD_ATTACH_PREFER_LLDB, and only if it is +# actually on the PATH. +@pytest.mark.parametrize( + "env_value, lldb_on_path, expect_lldb", + [ + (None, "/usr/bin/lldb", False), + ("", "/usr/bin/lldb", False), + ("0", "/usr/bin/lldb", False), + ("no", "/usr/bin/lldb", False), + ("1", "/usr/bin/lldb", True), + (" 1 ", "/usr/bin/lldb", True), + ("true", "/usr/bin/lldb", True), + ("TRUE", "/usr/bin/lldb", True), + ("yes", "/usr/bin/lldb", True), + ("Yes", "/usr/bin/lldb", True), + # Preferred, but not installed - must transparently fall back to gdb. + ("1", None, False), + ("true", None, False), + ], +) +def test_run_python_code_linux_dispatch( + acpp, injectors, env_value, lldb_on_path, expect_lldb +): + gdb, lldb = injectors + + with mock.patch.dict(os.environ, clear=False) as env: + env.pop("PYDEVD_ATTACH_PREFER_LLDB", None) + if env_value is not None: + env["PYDEVD_ATTACH_PREFER_LLDB"] = env_value + + with mock.patch.object(acpp.shutil, "which", return_value=lldb_on_path): + acpp.run_python_code_linux( + 123, "print(1)", connect_debugger_tracing=True, show_debug_info=0 + ) + + expected_call = mock.call(123, "print(1)", True, 0) + if expect_lldb: + assert lldb.mock_calls == [expected_call] + assert gdb.mock_calls == [] + else: + assert gdb.mock_calls == [expected_call] + assert lldb.mock_calls == [] + + +def test_run_python_code_linux_is_the_dispatcher(acpp): + """The Linux entry point must be the dispatcher, not gdb directly - otherwise the + preference is read at import time and never takes effect.""" + if acpp.IS_LINUX: + assert acpp.run_python_code is acpp.run_python_code_linux + assert acpp.run_python_code_linux is not acpp.run_python_code_linux_gdb + + +def test_run_python_code_linux_lldb_command(acpp): + target_dll = "/some/where/attach_linux_amd64.so" + lldb_prepare = os.path.normpath( + os.path.join(ATTACH_TO_PROCESS_DIR, "linux_and_mac", "lldb_prepare.py") + ) + assert os.path.exists(lldb_prepare) + + with mock.patch.object(acpp, "get_target_filename", return_value=target_dll): + with mock.patch.object(acpp.subprocess, "check_call") as check_call: + acpp.run_python_code_linux_lldb( + 4242, "print(1)", connect_debugger_tracing=True, show_debug_info=0 + ) + + check_call.assert_called_once() + (cmd,), kwargs = check_call.call_args + assert kwargs["shell"] + + assert cmd.startswith("lldb --no-lldbinit --script-language Python ") + assert "-o 'process attach --pid 4242'" in cmd + assert "-o 'command script import \"%s\"'" % (lldb_prepare,) in cmd + assert '-o \'load_lib_and_attach "%s" 0 "print(1)" 0\'' % (target_dll,) in cmd + assert "-o 'process detach'" in cmd + assert "-o 'script import os; os._exit(0)'" in cmd + + # lldb may have a builtin Python of a different version, so these must not leak in. + env = kwargs["env"] + assert "PYTHONPATH" not in env + assert "PYTHONIOENCODING" not in env + + +def test_run_python_code_linux_lldb_rejects_single_quotes(acpp): + with pytest.raises(AssertionError): + acpp.run_python_code_linux_lldb(4242, "print('hi')") + + +def test_run_python_code_linux_lldb_requires_target_dll(acpp): + with mock.patch.object(acpp, "get_target_filename", return_value=None): + with pytest.raises(RuntimeError) as ex: + acpp.run_python_code_linux_lldb(4242, "print(1)") + + assert "Could not find .so" in str(ex.value) diff --git a/tests/debugpy/server/test_cli.py b/tests/debugpy/server/test_cli.py index da6117d7..745df245 100644 --- a/tests/debugpy/server/test_cli.py +++ b/tests/debugpy/server/test_cli.py @@ -46,6 +46,7 @@ def cli_parser(): "target_kind", "wait_for_client", "parent_session_pid", + "linux_attach_prefer_lldb", ] } @@ -250,6 +251,88 @@ def test_script_parent_pid_with_listen_failure(cli): assert "--parent-session-pid requires --connect" in str(ex.value) +# Tests that --linux-attach-prefer-lldb is parsed, and defaults to off. +@pytest.mark.parametrize("prefer_lldb", ["", "prefer_lldb"]) +def test_linux_attach_prefer_lldb(cli, prefer_lldb): + args = ["--listen", "8888"] + if prefer_lldb: + args += ["--linux-attach-prefer-lldb"] + args += ["spam.py"] + + _, options = cli(args) + + assert options["linux_attach_prefer_lldb"] == bool(prefer_lldb) + + +def test_linux_attach_prefer_lldb_from_environment(cli): + args = ["--listen", "8888", "spam.py"] + with mock.patch.dict( + os.environ, {"DEBUGPY_EXTRA_ARGV": "--linux-attach-prefer-lldb"} + ): + _, options = cli(args) + + assert options["linux_attach_prefer_lldb"] + + +@pytest.mark.parametrize("prefer_lldb", [True, False]) +def test_attach_to_pid_propagates_lldb_preference(prefer_lldb): + """attach_to_pid() must set PYDEVD_ATTACH_PREFER_LLDB for the injector when, and + only when, --linux-attach-prefer-lldb was passed. add_code_to_python_process reads + that variable at call time to choose between lldb and gdb.""" + from debugpy.server import cli + + # Stub out the injector module so that no debugger is actually spawned. It is + # imported by bare name inside attach_to_pid(), after sys.path is extended. + fake_injector = mock.MagicMock() + + original_options = { + attr: getattr(cli.options, attr) + for attr in ( + "mode", + "address", + "wait_for_client", + "log_to", + "adapter_access_token", + "disable_sys_remote_exec", + "linux_attach_prefer_lldb", + "target", + ) + } + try: + cli.options.mode = "connect" + cli.options.address = ("127.0.0.1", 5678) + cli.options.wait_for_client = False + cli.options.log_to = None + cli.options.adapter_access_token = None + # Force the gdb/lldb code path rather than the PEP 768 sys.remote_exec one. + cli.options.disable_sys_remote_exec = True + cli.options.linux_attach_prefer_lldb = prefer_lldb + cli.options.target = os.getpid() + + # os.environ is mutated in place by attach_to_pid(), so isolate it. + with mock.patch.dict(os.environ, clear=False) as env: + env.pop("PYDEVD_ATTACH_PREFER_LLDB", None) + with mock.patch.dict( + sys.modules, {"add_code_to_python_process": fake_injector} + ): + cli.attach_to_pid() + + if prefer_lldb: + assert os.environ["PYDEVD_ATTACH_PREFER_LLDB"] == "1" + else: + assert "PYDEVD_ATTACH_PREFER_LLDB" not in os.environ + finally: + for attr, value in original_options.items(): + setattr(cli.options, attr, value) + + fake_injector.run_python_code.assert_called_once_with( + os.getpid(), + some.str, + connect_debugger_tracing=True, + show_debug_info=0, + ) + + def test_pep_768_remote_exec_called_with_backslash_path(): """Test that attach_to_pid() calls sys.remote_exec and writes valid Python to the temp file even when the temp path contains backslashes (Windows)."""