Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-131591: Implement PEP 768 #131592

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions Include/cpython/initconfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ typedef struct PyConfig {
int faulthandler;
int tracemalloc;
int perf_profiling;
int remote_debug;
int import_time;
int code_debug_ranges;
int show_ref_count;
Expand Down
9 changes: 9 additions & 0 deletions Include/cpython/pystate.h
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ typedef int (*Py_tracefunc)(PyObject *, PyFrameObject *, int, PyObject *);
#define PyTrace_C_RETURN 6
#define PyTrace_OPCODE 7

/* Remote debugger support */
# define MAX_SCRIPT_PATH_SIZE 512
typedef struct _remote_debugger_support {
int enabled;
Copy link
Contributor

Choose a reason for hiding this comment

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

I believe this field is unused. You added enabled here, but never reference it anywhere, and the offsets instead use:

.remote_debugging_enabled = offsetof(PyInterpreterState, config.remote_debug),

I think this line should just be removed (which makes sense, anyway - we want a per-process enablement, not a per-thread enablement).

int debugger_pending_call;
Comment on lines +35 to +36
Copy link
Contributor

Choose a reason for hiding this comment

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

We should probably be explicit about our sizes:

Suggested change
int enabled;
int debugger_pending_call;
int32_t enabled;
int32_t debugger_pending_call;

Granted int is 32 bit on all modern platforms, but given that this goes into the offsets and needs to be read from another process with potentially different integer types (like a 64-bit process attaching to a 32-bit one or vice versa) it's better to be explicit, I think.

Or, if you'd prefer, the offsets themselves could contain the size of the field - but I think that's overkill.

char debugger_script_path[MAX_SCRIPT_PATH_SIZE];
} _PyRemoteDebuggerSupport;

typedef struct _err_stackitem {
/* This struct represents a single execution context where we might
* be currently handling an exception. It is a per-coroutine state
Expand Down Expand Up @@ -202,6 +210,7 @@ struct _ts {
The PyThreadObject must hold the only reference to this value.
*/
PyObject *threading_local_sentinel;
_PyRemoteDebuggerSupport remote_debugger_support;
};

# define Py_C_RECURSION_LIMIT 5000
Expand Down
19 changes: 19 additions & 0 deletions Include/internal/pycore_debug_offsets.h
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ typedef struct _Py_DebugOffsets {
uint64_t id;
uint64_t next;
uint64_t threads_head;
uint64_t threads_main;
uint64_t gc;
uint64_t imports_modules;
uint64_t sysdict;
Expand Down Expand Up @@ -206,6 +207,15 @@ typedef struct _Py_DebugOffsets {
uint64_t gi_iframe;
uint64_t gi_frame_state;
} gen_object;

struct _debugger_support {
uint64_t eval_breaker;
uint64_t remote_debugger_support;
uint64_t remote_debugging_enabled;
uint64_t debugger_pending_call;
uint64_t debugger_script_path;
uint64_t debugger_script_path_size;
} debugger_support;
} _Py_DebugOffsets;


Expand All @@ -223,6 +233,7 @@ typedef struct _Py_DebugOffsets {
.id = offsetof(PyInterpreterState, id), \
.next = offsetof(PyInterpreterState, next), \
.threads_head = offsetof(PyInterpreterState, threads.head), \
.threads_main = offsetof(PyInterpreterState, threads.main), \
.gc = offsetof(PyInterpreterState, gc), \
.imports_modules = offsetof(PyInterpreterState, imports.modules), \
.sysdict = offsetof(PyInterpreterState, sysdict), \
Expand Down Expand Up @@ -326,6 +337,14 @@ typedef struct _Py_DebugOffsets {
.gi_iframe = offsetof(PyGenObject, gi_iframe), \
.gi_frame_state = offsetof(PyGenObject, gi_frame_state), \
}, \
.debugger_support = { \
.eval_breaker = offsetof(PyThreadState, eval_breaker), \
.remote_debugger_support = offsetof(PyThreadState, remote_debugger_support), \
.remote_debugging_enabled = offsetof(PyInterpreterState, config.remote_debug), \
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this field really go into the .debugger_support section? I think it should probably go into .interpreter_state instead, since everything else in this struct is specific to a single threadstate, except for this one field being per-interpreter.

.debugger_pending_call = offsetof(_PyRemoteDebuggerSupport, debugger_pending_call), \
.debugger_script_path = offsetof(_PyRemoteDebuggerSupport, debugger_script_path), \
.debugger_script_path_size = MAX_SCRIPT_PATH_SIZE, \
}, \
}


Expand Down
1 change: 1 addition & 0 deletions Include/internal/pycore_global_objects_fini_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Include/internal/pycore_global_strings.h
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@ struct _Py_global_strings {
STRUCT_FOR_ID(salt)
STRUCT_FOR_ID(sched_priority)
STRUCT_FOR_ID(scheduler)
STRUCT_FOR_ID(script)
STRUCT_FOR_ID(second)
STRUCT_FOR_ID(security_attributes)
STRUCT_FOR_ID(seek)
Expand Down
1 change: 0 additions & 1 deletion Include/internal/pycore_interp_structs.h
Original file line number Diff line number Diff line change
Expand Up @@ -936,7 +936,6 @@ struct _is {
_PyThreadStateImpl _initial_thread;
// _initial_thread should be the last field of PyInterpreterState.
// See https://github.com/python/cpython/issues/127117.

Copy link
Contributor

Choose a reason for hiding this comment

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

Accidental change?

#if !defined(Py_GIL_DISABLED) && defined(Py_STACKREF_DEBUG)
uint64_t next_stackref;
_Py_hashtable_t *open_stackrefs_table;
Expand Down
1 change: 1 addition & 0 deletions Include/internal/pycore_runtime_init_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Include/internal/pycore_sysmodule.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ extern int _PySys_ClearAttrString(PyInterpreterState *interp,
extern int _PySys_SetFlagObj(Py_ssize_t pos, PyObject *new_value);
extern int _PySys_SetIntMaxStrDigits(int maxdigits);

extern int _PySysRemoteDebug_SendExec(int pid, int tid, const char *debugger_script_path);

#ifdef __cplusplus
}
#endif
Expand Down
4 changes: 4 additions & 0 deletions Include/internal/pycore_unicodeobject_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

204 changes: 204 additions & 0 deletions Lib/test/test_sys.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@
import subprocess
import sys
import sysconfig
import socket
import test.support
from io import StringIO
from unittest import mock
from test import support
from test.support import os_helper
from test.support.script_helper import assert_python_ok, assert_python_failure
from test.support import threading_helper
from test.support import import_helper
from test.support import force_not_colorized
from test.support import SHORT_TIMEOUT
try:
from test.support import interpreters
except ImportError:
Expand Down Expand Up @@ -1923,5 +1927,205 @@ def write(self, s):
self.assertEqual(out, b"")
self.assertEqual(err, b"")


def _supports_remote_attaching():
PROCESS_VM_READV_SUPPORTED = False

try:
from _testexternalinspection import PROCESS_VM_READV_SUPPORTED
except ImportError:
pass

return PROCESS_VM_READV_SUPPORTED

@unittest.skipIf(not sys.is_remote_debug_enabled(), "Remote debugging is not enabled")
@unittest.skipIf(sys.platform != "darwin" and sys.platform != "linux" and sys.platform != "win32",
"Test only runs on Linux and MacOS")
@unittest.skipIf(sys.platform == "linux" and not _supports_remote_attaching(),
"Test only runs on Linux with process_vm_readv support")
class TestRemoteExec(unittest.TestCase):
def tearDown(self):
test.support.reap_children()

def _run_remote_exec_test(self, script_code, python_args=None, env=None, prologue=''):
# Create the script that will be remotely executed
script = os_helper.TESTFN + '_remote.py'
self.addCleanup(os_helper.unlink, script)

with open(script, 'w') as f:
f.write(script_code)

# Create and run the target process
target = os_helper.TESTFN + '_target.py'
self.addCleanup(os_helper.unlink, target)

# Find an available port for the socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('localhost', 0))
port = s.getsockname()[1]

with open(target, 'w') as f:
f.write(f'''
import sys
import time
import socket

# Connect to the test process
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('localhost', {port}))

# Signal that the process is ready
sock.sendall(b"ready")

{prologue}

print("Target process running...")

# Wait for remote script to be executed
# (the execution will happen as the following
# code is processed as soon as the recv call
# unblocks)
sock.recv(1024)

# Write confirmation back
sock.sendall(b"executed")
sock.close()
''')

# Start the target process and capture its output
cmd = [sys.executable]
if python_args:
cmd.extend(python_args)
cmd.append(target)

# Create a socket server to communicate with the target process
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.bind(('localhost', port))
server_socket.settimeout(10.0) # Set a timeout to prevent hanging
server_socket.listen(1)

with subprocess.Popen(cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env) as proc:
try:
# Accept connection from target process
client_socket, _ = server_socket.accept()

# Wait for process to be ready
response = client_socket.recv(1024)
self.assertEqual(response, b"ready")

# Try remote exec on the target process
sys.remote_exec(proc.pid, script)

# Signal script to continue
client_socket.sendall(b"continue")

# Wait for execution confirmation
response = client_socket.recv(1024)
self.assertEqual(response, b"executed")

# Return output for test verification
stdout, stderr = proc.communicate(timeout=10.0)
return proc.returncode, stdout, stderr
except PermissionError:
self.skipTest("Insufficient permissions to execute code in remote process")
finally:
if 'client_socket' in locals():
client_socket.close()
server_socket.close()
proc.kill()
proc.terminate()
proc.wait(timeout=SHORT_TIMEOUT)

def test_remote_exec(self):
"""Test basic remote exec functionality"""
script = '''
print("Remote script executed successfully!")
'''
returncode, stdout, stderr = self._run_remote_exec_test(script)
# self.assertEqual(returncode, 0)
self.assertIn(b"Remote script executed successfully!", stdout)
self.assertEqual(stderr, b"")

def test_remote_exec_with_self_process(self):
"""Test remote exec with the target process being the same as the test process"""

code = 'import sys;print("Remote script executed successfully!", file=sys.stderr)'
file = os_helper.TESTFN + '_remote_self.py'
with open(file, 'w') as f:
f.write(code)
self.addCleanup(os_helper.unlink, file)
with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr:
with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout:
sys.remote_exec(os.getpid(), os.path.abspath(file))
print("Done")
self.assertEqual(mock_stderr.getvalue(), "Remote script executed successfully!\n")
self.assertEqual(mock_stdout.getvalue(), "Done\n")

def test_remote_exec_raises_audit_event(self):
"""Test remote exec raises an audit event"""
prologue = '''\
import sys
def audit_hook(event, arg):
print(f"Audit event: {event}, arg: {arg}")
sys.addaudithook(audit_hook)
'''
script = '''
print("Remote script executed successfully!")
'''
returncode, stdout, stderr = self._run_remote_exec_test(script, prologue=prologue)
self.assertEqual(returncode, 0)
self.assertIn(b"Remote script executed successfully!", stdout)
self.assertIn(b"Audit event: remote_debugger_script, arg: ", stdout)
self.assertEqual(stderr, b"")

def test_remote_exec_with_exception(self):
"""Test remote exec with an exception raised in the target process

The exception should be raised in the main thread of the target process
but not crash the target process.
"""
script = '''
raise Exception("Remote script exception")
'''
returncode, stdout, stderr = self._run_remote_exec_test(script)
self.assertEqual(returncode, 0)
self.assertIn(b"Remote script exception", stderr)
self.assertEqual(stdout.strip(), b"Target process running...")

def test_remote_exec_disabled_by_env(self):
"""Test remote exec is disabled when PYTHON_DISABLE_REMOTE_DEBUG is set"""
env = os.environ.copy()
env['PYTHON_DISABLE_REMOTE_DEBUG'] = '1'
with self.assertRaisesRegex(RuntimeError, "Remote debugging is not enabled in the remote process"):
self._run_remote_exec_test("print('should not run')", env=env)

def test_remote_exec_disabled_by_xoption(self):
"""Test remote exec is disabled with -Xdisable-remote-debug"""
with self.assertRaisesRegex(RuntimeError, "Remote debugging is not enabled in the remote process"):
self._run_remote_exec_test("print('should not run')", python_args=['-Xdisable-remote-debug'])

def test_remote_exec_invalid_pid(self):
"""Test remote exec with invalid process ID"""
with self.assertRaises(OSError):
sys.remote_exec(99999, "print('should not run')")

def test_remote_exec_syntax_error(self):
"""Test remote exec with syntax error in script"""
script = '''
this is invalid python code
'''
returncode, stdout, stderr = self._run_remote_exec_test(script)
self.assertEqual(returncode, 0)
self.assertIn(b"SyntaxError", stderr)
self.assertEqual(stdout.strip(), b"Target process running...")

def test_remote_exec_invalid_script_path(self):
"""Test remote exec with invalid script path"""
with self.assertRaises(OSError):
sys.remote_exec(os.getpid(), "invalid_script_path")

if __name__ == "__main__":
unittest.main()
1 change: 1 addition & 0 deletions Makefile.pre.in
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ PYTHON_OBJS= \
Python/suggestions.o \
Python/perf_trampoline.o \
Python/perf_jit_trampoline.o \
Python/remote_debugging.o \
Python/$(DYNLOADFILE) \
$(LIBOBJS) \
$(MACHDEP_OBJS) \
Expand Down
1 change: 1 addition & 0 deletions PCbuild/_freeze_module.vcxproj
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@
<ClCompile Include="..\Python\Python-tokenize.c" />
<ClCompile Include="..\Python\pytime.c" />
<ClCompile Include="..\Python\qsbr.c" />
<ClCompile Include="..\Python\remote_debugging.c" />
<ClCompile Include="..\Python\specialize.c" />
<ClCompile Include="..\Python\structmember.c" />
<ClCompile Include="..\Python\suggestions.c" />
Expand Down
3 changes: 3 additions & 0 deletions PCbuild/_freeze_module.vcxproj.filters
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,9 @@
<ClCompile Include="..\Objects\sliceobject.c">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="..\Python\remote_debugging.c">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="..\Python\specialize.c">
<Filter>Source Files</Filter>
</ClCompile>
Expand Down
2 changes: 2 additions & 0 deletions PCbuild/build.bat
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ if "%~1"=="--experimental-jit" (set UseJIT=true) & (set UseTIER2=1) & shift & go
if "%~1"=="--experimental-jit-off" (set UseJIT=true) & (set UseTIER2=3) & shift & goto CheckOpts
if "%~1"=="--experimental-jit-interpreter" (set UseTIER2=4) & shift & goto CheckOpts
if "%~1"=="--experimental-jit-interpreter-off" (set UseTIER2=6) & shift & goto CheckOpts
if "%~1"=="--without-remote-debug" (set DisableRemoteDebug=true) & shift & goto CheckOpts
if "%~1"=="--pystats" (set PyStats=1) & shift & goto CheckOpts
if "%~1"=="--tail-call-interp" (set UseTailCallInterp=true) & shift & goto CheckOpts
rem These use the actual property names used by MSBuild. We could just let
Expand Down Expand Up @@ -192,6 +193,7 @@ echo on
/p:UseTIER2=%UseTIER2%^
/p:PyStats=%PyStats%^
/p:UseTailCallInterp=%UseTailCallInterp%^
/p:DisableRemoteDebug=%DisableRemoteDebug%^
%1 %2 %3 %4 %5 %6 %7 %8 %9

@echo off
Expand Down
Loading
Loading