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"