Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Warning · Non-blocking recommendation

run_python_code_linux_lldb duplicates the macOS lldb injector, including command construction, quoting, environment scrubbing, and subprocess execution. This creates two sources of truth for lldb fixes. Please extract the shared lldb flow into a helper, or explicitly document why the implementations must remain mirrored.



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:
Expand Down
5 changes: 5 additions & 0 deletions src/debugpy/adapter/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions src/debugpy/server/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
[--parent-session-pid <pid>]]
[--adapter-access-token <token>]
[--disable-sys-remote-exec]]
[--linux-attach-prefer-lldb]
{1}
[<arg>]...
""".format(
Expand All @@ -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()
Expand Down Expand Up @@ -189,6 +193,7 @@ def do(arg, it):
("--parent-session-pid", "<pid>", set_arg("parent_session_pid", lambda x: int(x) if x else None)),
("--adapter-access-token", "<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.
Expand Down Expand Up @@ -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

Expand Down
145 changes: 145 additions & 0 deletions tests/debugpy/server/test_add_code_to_python_process.py
Original file line number Diff line number Diff line change
@@ -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)
83 changes: 83 additions & 0 deletions tests/debugpy/server/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def cli_parser():
"target_kind",
"wait_for_client",
"parent_session_pid",
"linux_attach_prefer_lldb",
]
}

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