https://github.com/python/cpython/commit/9a9bceb95387974afb331c46ce68465ee6c408af
commit: 9a9bceb95387974afb331c46ce68465ee6c408af
branch: 3.14
author: Ćukasz Langa <[email protected]>
committer: pablogsal <[email protected]>
date: 2026-06-28T16:43:14+02:00
summary:
[3.14] gh-151029: Fix sys.remote_exec() unable to find writable memory when
libpython replaced on disk (GH-151032) (#152464)
files:
A
Misc/NEWS.d/next/Core_and_Builtins/2026-06-06-20-15-08.gh-issue-151029.A33CKK.rst
M Lib/test/test_sys.py
M Python/remote_debug.h
diff --git a/Lib/test/test_sys.py b/Lib/test/test_sys.py
index 2a80a53db2fc631..4cd9b7f4af25d4c 100644
--- a/Lib/test/test_sys.py
+++ b/Lib/test/test_sys.py
@@ -6,6 +6,7 @@
import operator
import os
import random
+import shutil
import socket
import struct
import subprocess
@@ -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)
@@ -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)
@@ -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)
@@ -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!")'
@@ -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'
diff --git
a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-06-20-15-08.gh-issue-151029.A33CKK.rst
b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-06-20-15-08.gh-issue-151029.A33CKK.rst
new file mode 100644
index 000000000000000..cbfe5952627ad8a
--- /dev/null
+++
b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-06-20-15-08.gh-issue-151029.A33CKK.rst
@@ -0,0 +1,2 @@
+On Linux, fix :func:`sys.remote_exec` unable to find remote writable memory
+when ``libpython`` replaced on disk.
diff --git a/Python/remote_debug.h b/Python/remote_debug.h
index 6a7706bb76e8ab1..38dee987e2f5032 100644
--- a/Python/remote_debug.h
+++ b/Python/remote_debug.h
@@ -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)) { \
@@ -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)
@@ -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;
@@ -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;
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]