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
68 changes: 65 additions & 3 deletions src/debugpy/_vendored/pydevd/_pydev_bundle/pydev_monkey.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/debugpy/_vendored/pydevd/pydevd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
66 changes: 66 additions & 0 deletions src/debugpy/_vendored/pydevd/tests_python/test_pydev_monkey.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# coding: utf-8
import importlib
import os
import subprocess
import sys
import types
from typing import Any, Generator

import pytest
Expand Down Expand Up @@ -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"
36 changes: 36 additions & 0 deletions tests/debugpy/test_multiproc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down