Skip to content
Merged
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
94 changes: 92 additions & 2 deletions Lib/test/test_sys.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import operator
import os
import random
import shutil
import socket
import struct
import subprocess
Expand Down Expand Up @@ -1979,7 +1980,8 @@ def tearDown(self):
test.support.reap_children()

def _run_remote_exec_test(self, script_code, python_args=None, env=None,
prologue='',
python_executable=None, prologue='',
after_ready=None,
script_path=os_helper.TESTFN + '_remote.py'):
# Create the script that will be remotely executed
self.addCleanup(os_helper.unlink, script_path)
Expand Down Expand Up @@ -2027,7 +2029,10 @@ def _run_remote_exec_test(self, script_code, python_args=None, env=None,
''')

# Start the target process and capture its output
cmd = [sys.executable]
if python_executable is None:
python_executable = sys.executable

cmd = [python_executable]
if python_args:
cmd.extend(python_args)
cmd.append(target)
Expand All @@ -2052,6 +2057,9 @@ def _run_remote_exec_test(self, script_code, python_args=None, env=None,
response = client_socket.recv(1024)
self.assertEqual(response, b"ready")

if after_ready is not None:
after_ready(proc)

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

Expand All @@ -2074,6 +2082,19 @@ def _run_remote_exec_test(self, script_code, python_args=None, env=None,
proc.terminate()
proc.wait(timeout=SHORT_TIMEOUT)

def _run_remote_exec_with_deleted_mapping(self, deleted_path, **kwargs):
def delete_loaded_mapping(proc):
os_helper.unlink(deleted_path)
with open(f'/proc/{proc.pid}/maps', encoding='utf-8') as maps:
self.assertIn(f'{deleted_path} (deleted)', maps.read())

script = 'print("Remote script executed successfully!")'
returncode, stdout, stderr = self._run_remote_exec_test(
script, after_ready=delete_loaded_mapping, **kwargs)
self.assertEqual(returncode, 0)
self.assertIn(b"Remote script executed successfully!", stdout)
self.assertEqual(stderr, b"")

def test_remote_exec(self):
"""Test basic remote exec functionality"""
script = 'print("Remote script executed successfully!")'
Expand Down Expand Up @@ -2200,6 +2221,75 @@ def test_remote_exec_invalid_script_path(self):
with self.assertRaises(OSError):
sys.remote_exec(os.getpid(), "invalid_script_path")

@unittest.skipUnless(sys.platform == 'linux', 'Linux-only regression test')
@unittest.skipUnless(
sysconfig.get_config_var('Py_ENABLE_SHARED') == 1,
'requires a shared libpython build')
def test_remote_exec_deleted_libpython(self):
"""Test remote exec when the target libpython was deleted."""
build_dir = sysconfig.get_config_var('abs_builddir')
ldlibrary = sysconfig.get_config_var('LDLIBRARY')
instsoname = sysconfig.get_config_var('INSTSONAME')
if not build_dir or not ldlibrary or not instsoname:
self.skipTest('cannot determine shared libpython location')

source_libpython = os.path.join(build_dir, instsoname)
if not os.path.exists(source_libpython):
self.skipTest(f'{source_libpython!r} does not exist')

with os_helper.temp_dir() as lib_dir:
copied_libpython = os.path.join(lib_dir, instsoname)
shutil.copy2(source_libpython, copied_libpython)
if ldlibrary != instsoname:
os.symlink(instsoname, os.path.join(lib_dir, ldlibrary))

env = os.environ.copy()
ld_library_path = env.get('LD_LIBRARY_PATH')
env['LD_LIBRARY_PATH'] = lib_dir if not ld_library_path else (
lib_dir + os.pathsep + ld_library_path)

self._run_remote_exec_with_deleted_mapping(copied_libpython,
env=env)

@unittest.skipUnless(sys.platform == 'linux', 'Linux-only regression test')
@unittest.skipUnless(
sysconfig.get_config_var('Py_ENABLE_SHARED') == 0,
'requires a static Python build')
def test_remote_exec_deleted_static_executable(self):
"""Test remote exec when the target static executable was deleted."""
build_dir = sysconfig.get_config_var('abs_builddir')
srcdir = sysconfig.get_config_var('srcdir')
if not build_dir or not srcdir:
self.skipTest('cannot determine build-tree locations')

pybuilddir_txt = os.path.join(build_dir, 'pybuilddir.txt')
if not os.path.exists(pybuilddir_txt):
self.skipTest(f'{pybuilddir_txt!r} does not exist')

with open(pybuilddir_txt, encoding='utf-8') as pybuilddir_file:
pybuilddir = pybuilddir_file.read().strip()
source_ext_dir = os.path.join(build_dir, pybuilddir)
if not os.path.isdir(source_ext_dir):
self.skipTest(f'{source_ext_dir!r} does not exist')

with os_helper.temp_dir() as copied_root:
copied_build_dir = os.path.join(copied_root, 'build')
copied_pybuilddir = os.path.join(copied_build_dir, pybuilddir)
os.makedirs(os.path.dirname(copied_pybuilddir))
os.symlink(os.path.join(srcdir, 'Lib'),
os.path.join(copied_root, 'Lib'))
os.symlink(source_ext_dir, copied_pybuilddir)
shutil.copy2(pybuilddir_txt,
os.path.join(copied_build_dir, 'pybuilddir.txt'))

copied_python = os.path.join(copied_build_dir,
os.path.basename(sys.executable))
shutil.copy2(sys.executable, copied_python)

self._run_remote_exec_with_deleted_mapping(
copied_python, python_args=['-S'],
python_executable=copied_python)

def test_remote_exec_in_process_without_debug_fails_envvar(self):
"""Test remote exec in a process without remote debugging enabled"""
script = os_helper.TESTFN + '_remote.py'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
On Linux, fix :func:`sys.remote_exec` unable to find remote writable memory
when ``libpython`` replaced on disk.
149 changes: 141 additions & 8 deletions Python/remote_debug.h
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ extern "C" {
# define HAVE_PROCESS_VM_READV 0
#endif

static inline int
_Py_RemoteDebug_HasPermissionError(void)
{
return PyErr_Occurred()
&& PyErr_ExceptionMatches(PyExc_PermissionError);
}

#define _set_debug_exception_cause(exception, format, ...) \
do { \
if (!PyErr_ExceptionMatches(PyExc_PermissionError)) { \
Expand Down Expand Up @@ -686,6 +693,106 @@ search_elf_file_for_section(
return result;
}

static const char *
find_debug_cookie(const char *buffer, size_t len)
{
const char *cookie = _Py_Debug_Cookie;
const size_t cookie_len = sizeof(_Py_Debug_Cookie) - 1;
if (len < cookie_len) {
return NULL;
}

size_t pos = 0;
size_t last = len - cookie_len;
while (pos <= last) {
const char *candidate = memchr(
buffer + pos, cookie[0], last - pos + 1);
if (candidate == NULL) {
return NULL;
}
pos = (size_t)(candidate - buffer);
if (memcmp(candidate, cookie, cookie_len) == 0) {
return candidate;
}
pos++;
}
return NULL;
}

static int
linux_map_path_is_deleted(const char *path)
{
static const char deleted_suffix[] = " (deleted)";
size_t path_len = strlen(path);
size_t suffix_len = sizeof(deleted_suffix) - 1;
return path_len >= suffix_len
&& strcmp(path + path_len - suffix_len, deleted_suffix) == 0;
}

static int
linux_map_perms_are_readwrite(const char *perms)
{
return perms[0] == 'r' && perms[1] == 'w';
}

static uintptr_t
scan_linux_mapping_for_pyruntime_cookie(
proc_handle_t *handle,
uintptr_t start,
uintptr_t end)
{
if (end <= start) {
return 0;
}

const size_t cookie_len = sizeof(_Py_Debug_Cookie) - 1;
const size_t overlap = cookie_len - 1;
const size_t chunk_size = 1024 * 1024;
char *buffer = PyMem_Malloc(chunk_size);
if (buffer == NULL) {
PyErr_NoMemory();
_set_debug_exception_cause(PyExc_MemoryError,
"Cannot allocate memory while scanning PID %d for PyRuntime cookie",
handle->pid);
return 0;
}

uintptr_t retval = 0;
uintptr_t mapping_size = end - start;
uintptr_t offset = 0;
while (offset < mapping_size) {
uintptr_t remaining = mapping_size - offset;
size_t wanted = remaining > chunk_size
? chunk_size : (size_t)remaining;
if (_Py_RemoteDebug_ReadRemoteMemory(
handle, start + offset, wanted, buffer) < 0) {
if (_Py_RemoteDebug_HasPermissionError()) {
goto exit;
}
// A candidate mapping can disappear or contain unreadable holes while
// the target process keeps running. Treat those as non-matches and
// keep scanning other candidate mappings.
PyErr_Clear();
}
else {
const char *hit = find_debug_cookie(buffer, wanted);
if (hit != NULL) {
retval = start + offset + (uintptr_t)(hit - buffer);
goto exit;
}
}

if (wanted <= overlap) {
break;
}
offset += wanted - overlap;
}

exit:
PyMem_Free(buffer);
return retval;
}

static uintptr_t
search_linux_map_for_section(proc_handle_t *handle, const char* secname, const char* substr,
section_validator_t validator)
Expand Down Expand Up @@ -739,16 +846,22 @@ search_linux_map_for_section(proc_handle_t *handle, const char* secname, const c
linelen = 0;

unsigned long start = 0;
unsigned long path_pos = 0;
sscanf(line, "%lx-%*x %*s %*s %*s %*s %ln", &start, &path_pos);
unsigned long end = 0;
int path_pos = 0;
char perms[5] = "";
int fields = sscanf(line, "%lx-%lx %4s %*s %*s %*s %n",
&start, &end, perms, &path_pos);

if (!path_pos) {
if (fields < 3 || !path_pos) {
// Line didn't match our format string. This shouldn't be
// possible, but let's be defensive and skip the line.
continue;
}

const char *path = line + path_pos;
if (path[0] == '\0') {
continue;
}
if (path[0] == '[' && path[strlen(path)-1] == ']') {
// Skip [heap], [stack], [anon:cpython:pymalloc], etc.
continue;
Expand All @@ -762,11 +875,31 @@ search_linux_map_for_section(proc_handle_t *handle, const char* secname, const c
}

if (strstr(filename, substr)) {
PyErr_Clear();
retval = search_elf_file_for_section(handle, secname, start, path);
if (retval
&& (validator == NULL || validator(handle, retval)))
{
int deleted_pyruntime_mapping =
strcmp(secname, "PyRuntime") == 0
&& linux_map_path_is_deleted(path);
if (deleted_pyruntime_mapping
&& linux_map_perms_are_readwrite(perms)) {
PyErr_Clear();
retval = scan_linux_mapping_for_pyruntime_cookie(
handle, (uintptr_t)start, (uintptr_t)end);
}
if (!deleted_pyruntime_mapping
&& retval == 0 && !PyErr_Occurred()) {
PyErr_Clear();
retval = search_elf_file_for_section(
handle, secname, start, path);
}
if (retval) {
if (validator == NULL || validator(handle, retval)) {
break;
}
if (_Py_RemoteDebug_HasPermissionError()) {
retval = 0;
break;
}
}
else if (_Py_RemoteDebug_HasPermissionError()) {
break;
}
retval = 0;
Expand Down
Loading