diff --git a/Lib/test/test_sys.py b/Lib/test/test_sys.py index 02c70403185f60..13b8d8c54abe6c 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 @@ -1983,7 +1984,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) @@ -2031,7 +2033,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) @@ -2056,6 +2061,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) @@ -2204,6 +2212,95 @@ 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) + + def delete_loaded_libpython(proc): + os_helper.unlink(copied_libpython) + with open(f'/proc/{proc.pid}/maps', encoding='utf-8') as maps: + self.assertIn(f'{copied_libpython} (deleted)', + maps.read()) + + script = 'print("Remote script executed successfully!")' + returncode, stdout, stderr = self._run_remote_exec_test( + script, env=env, after_ready=delete_loaded_libpython) + self.assertEqual(returncode, 0) + self.assertIn(b"Remote script executed successfully!", stdout) + self.assertEqual(stderr, b"") + + @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) + + def delete_loaded_executable(proc): + os_helper.unlink(copied_python) + with open(f'/proc/{proc.pid}/maps', encoding='utf-8') as maps: + self.assertIn(f'{copied_python} (deleted)', + maps.read()) + + script = 'print("Remote script executed successfully!")' + returncode, stdout, stderr = self._run_remote_exec_test( + script, python_args=['-S'], python_executable=copied_python, + after_ready=delete_loaded_executable) + self.assertEqual(returncode, 0) + self.assertIn(b"Remote script executed successfully!", stdout) + self.assertEqual(stderr, b"") + 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 00000000000000..cbfe5952627ad8 --- /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 6fecc23502b46e..4df666ee169315 100644 --- a/Python/remote_debug.h +++ b/Python/remote_debug.h @@ -781,6 +781,103 @@ 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; + } + 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 +932,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 +961,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;