https://github.com/python/cpython/commit/a69d0fc41ef339378022f1c0190a9692cb276a7f
commit: a69d0fc41ef339378022f1c0190a9692cb276a7f
branch: main
author: Ɓukasz Langa <[email protected]>
committer: pablogsal <[email protected]>
date: 2026-06-27T16:21:15Z
summary:

gh-151029: Fix sys.remote_exec() unable to find writable memory when libpython 
replaced on disk (#151032)

Co-authored-by: Pablo Galindo <[email protected]>

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 f40da0b79aa4790..9f686e289c8f3be 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
@@ -2003,7 +2004,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)
@@ -2051,7 +2053,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)
@@ -2076,6 +2081,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)
 
@@ -2098,6 +2106,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!")'
@@ -2224,6 +2245,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 6fecc23502b46ef..7b7380d25bf496f 100644
--- a/Python/remote_debug.h
+++ b/Python/remote_debug.h
@@ -781,6 +781,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)
@@ -835,16 +935,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;
@@ -858,8 +964,21 @@ 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);
+            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;

_______________________________________________
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]

Reply via email to