From 28d5ed94d8d74a733d87418488f7990c81429d48 Mon Sep 17 00:00:00 2001 From: Yashvardhan Thanvi Date: Fri, 16 Oct 2026 08:51:42 +0530 Subject: [PATCH] Restore process creation functions in pydevd.stoptrace() settrace(patch_multiprocessing=True) replaces os.exec*, os.spawn*, os.posix_spawn*, os.fork, _posixsubprocess.fork_exec, subprocess._fork_exec and, on Windows, _winapi.CreateProcess, so that child Python processes connect to the debugger. stoptrace() undid the thread patches but left these in place. After it ran, every new Python child was still rewritten to call pydevd.settrace() against an adapter that no longer existed, and the child exited with ConnectionRefusedError before running its own code. A later debugpy.listen() (with listen.called reset by hand, as in the issue) timed out as well, since the adapter it launches goes through the same patched function. Add undo_patch_new_process_functions() and call it from stoptrace() next to undo_patch_thread_modules(). It puts each original function back only while pydevd's wrapper is still the installed one, so a layer added on top later (gevent's os.fork, for example) stays in place. The original_* attributes are kept, because such a layer, or any other saved reference, still calls the wrapper, which looks the original up by that name. monkey_patch_module() now tags its wrappers so that the undo can recognise them, and patches again when it finds the original restored, so a later settrace() installs the wrappers again. Fixes #2014 --- .../pydevd/_pydev_bundle/pydev_monkey.py | 68 ++++++++++++++++++- src/debugpy/_vendored/pydevd/pydevd.py | 3 +- .../pydevd/tests_python/test_pydev_monkey.py | 66 ++++++++++++++++++ tests/debugpy/test_multiproc.py | 36 ++++++++++ 4 files changed, 169 insertions(+), 4 deletions(-) diff --git a/src/debugpy/_vendored/pydevd/_pydev_bundle/pydev_monkey.py b/src/debugpy/_vendored/pydevd/_pydev_bundle/pydev_monkey.py index 09fcadec3..6cfa2621f 100644 --- a/src/debugpy/_vendored/pydevd/_pydev_bundle/pydev_monkey.py +++ b/src/debugpy/_vendored/pydevd/_pydev_bundle/pydev_monkey.py @@ -701,15 +701,28 @@ def patch_arg_str_win(arg_str): def monkey_patch_module(module, funcname, create_func) -> None: if hasattr(module, funcname): original_name = "original_" + funcname - if not hasattr(module, original_name): - setattr(module, original_name, getattr(module, funcname)) - setattr(module, funcname, create_func(original_name)) + current = getattr(module, funcname) + # Patch if not patched yet, or again after undo_monkey_patch_module() restored the original. + if not hasattr(module, original_name) or current is getattr(module, original_name): + setattr(module, original_name, current) + new_func = create_func(original_name) + new_func.pydevd_original_name = original_name + setattr(module, funcname, new_func) def monkey_patch_os(funcname, create_func) -> None: monkey_patch_module(os, funcname, create_func) +def undo_monkey_patch_module(module, funcname) -> None: + original_name = "original_" + funcname + current = getattr(module, funcname, None) + # Only restore when our wrapper is still the installed function (something else may have + # wrapped it since), and keep original_* so that references to the wrapper still work. + if hasattr(module, original_name) and getattr(current, "pydevd_original_name", None) == original_name: + setattr(module, funcname, getattr(module, original_name)) + + def warn_multiproc() -> None: pass # TODO: Provide logging as messages to the IDE. # pydev_log.error_once( @@ -1111,6 +1124,55 @@ def patch_new_process_functions_with_warning() -> None: monkey_patch_module(_subprocess, "CreateProcess", create_CreateProcessWarnMultiproc) +def undo_patch_new_process_functions() -> None: + """ + Restores the functions replaced by patch_new_process_functions() or + patch_new_process_functions_with_warning(). + """ + for funcname in ( + "execl", + "execle", + "execlp", + "execlpe", + "execv", + "execve", + "execvp", + "execvpe", + "spawnl", + "spawnle", + "spawnlp", + "spawnlpe", + "spawnv", + "spawnve", + "spawnvp", + "spawnvpe", + "posix_spawn", + "posix_spawnp", + "fork", + ): + undo_monkey_patch_module(os, funcname) + + if not IS_JYTHON: + if not IS_WINDOWS: + try: + import _posixsubprocess + + undo_monkey_patch_module(_posixsubprocess, "fork_exec") + except ImportError: + pass + + import subprocess + + undo_monkey_patch_module(subprocess, "_fork_exec") + else: + # Windows + try: + import _subprocess + except ImportError: + import _winapi as _subprocess + undo_monkey_patch_module(_subprocess, "CreateProcess") + + class _NewThreadStartupWithTrace: def __init__(self, original_func, args, kwargs) -> None: self.original_func: Any = original_func diff --git a/src/debugpy/_vendored/pydevd/pydevd.py b/src/debugpy/_vendored/pydevd/pydevd.py index cab1f902a..7605533a4 100644 --- a/src/debugpy/_vendored/pydevd/pydevd.py +++ b/src/debugpy/_vendored/pydevd/pydevd.py @@ -3359,9 +3359,10 @@ def stoptrace(): except: pass - from _pydev_bundle.pydev_monkey import undo_patch_thread_modules + from _pydev_bundle.pydev_monkey import undo_patch_thread_modules, undo_patch_new_process_functions undo_patch_thread_modules() + undo_patch_new_process_functions() # Either or both standard streams can be closed at this point, # in which case flush() will fail. diff --git a/src/debugpy/_vendored/pydevd/tests_python/test_pydev_monkey.py b/src/debugpy/_vendored/pydevd/tests_python/test_pydev_monkey.py index 55adac33f..37a3959ab 100644 --- a/src/debugpy/_vendored/pydevd/tests_python/test_pydev_monkey.py +++ b/src/debugpy/_vendored/pydevd/tests_python/test_pydev_monkey.py @@ -1,6 +1,9 @@ # coding: utf-8 +import importlib import os +import subprocess import sys +import types from typing import Any, Generator import pytest @@ -512,3 +515,66 @@ def test_monkey_patch_args_c_with_bytes() -> None: # The result should contain our patched debugpy setup somewhere result_str: str = b"".join(item.encode() if isinstance(item, str) else item for item in result).decode() assert "pydevd.settrace" in result_str + + +@pytest.mark.parametrize("patch_func", ["patch_new_process_functions", "patch_new_process_functions_with_warning"]) +def test_undo_patch_new_process_functions(patch_func) -> None: + modules = [os, subprocess] + for name in ("_posixsubprocess", "_winapi"): + try: + modules.append(importlib.import_module(name)) + except ImportError: + pass + + before = [dict(vars(module)) for module in modules] + + def changed_functions(): + # original_* attributes are kept on purpose, so only look at the rest. + return sorted( + (module.__name__, name) + for module, saved in zip(modules, before) + for name in set(vars(module)) | set(saved) + if not name.startswith("original_") and vars(module).get(name) is not saved.get(name) + ) + + getattr(pydev_monkey, patch_func)() + try: + assert changed_functions() + finally: + pydev_monkey.undo_patch_new_process_functions() + + assert changed_functions() == [] + + +def test_undo_monkey_patch_module() -> None: + def run(): + return "original" + + module = types.ModuleType("fake_module") + module.run = run + + def create_run(original_name): + def new_run(): + return "patched " + getattr(module, original_name)() + + return new_run + + pydev_monkey.monkey_patch_module(module, "run", create_run) + assert module.run() == "patched original" + pydev_monkey.undo_monkey_patch_module(module, "run") + assert module.run is run + + # It can be patched again after the undo. + pydev_monkey.monkey_patch_module(module, "run", create_run) + patched_run = module.run + assert patched_run() == "patched original" + + # Something else wraps our wrapper: the undo must leave that layer in place, and our + # wrapper, still referenced by it, must keep working. + def outer_run(): + return "outer " + patched_run() + + module.run = outer_run + pydev_monkey.undo_monkey_patch_module(module, "run") + assert module.run is outer_run + assert module.run() == "outer patched original" diff --git a/tests/debugpy/test_multiproc.py b/tests/debugpy/test_multiproc.py index 591bfe827..51f197633 100644 --- a/tests/debugpy/test_multiproc.py +++ b/tests/debugpy/test_multiproc.py @@ -508,6 +508,42 @@ def before_connect(address): child_session.wait_for_terminated() +@pytest.mark.parametrize("run", runners.all_attach_socket) +def test_subprocess_after_stoptrace(pyfile, target, run): + @pyfile + def code_to_debug(): + import debuggee + import subprocess + import sys + from debuggee import backchannel + + debuggee.setup() + backchannel.send("running") + backchannel.receive() + + import pydevd + + pydevd.stoptrace() + + # The debugger is gone, so the child must start as a plain process. + child = subprocess.run( + [sys.executable, "-c", "print('child ok')"], + capture_output=True, + timeout=30, + ) + backchannel.send([child.returncode, child.stdout.decode().strip()]) + + with debug.Session() as session: + backchannel = session.open_backchannel() + with run(session, target(code_to_debug)): + pass + + assert backchannel.receive() == "running" + session.disconnect() + backchannel.send("proceed") + assert backchannel.receive() == [0, "child ok"] + + @pytest.mark.parametrize("run", runners.all_launch) @pytest.mark.skipif( sys.platform != "win32", reason="job objects are specific to Windows"