|
12 | 12 | import sys |
13 | 13 | import sysconfig |
14 | 14 | import test.support |
| 15 | +from io import StringIO |
| 16 | +from unittest import mock |
15 | 17 | from test import support |
16 | 18 | from test.support import os_helper |
17 | 19 | from test.support.script_helper import assert_python_ok, assert_python_failure |
18 | 20 | from test.support import threading_helper |
19 | 21 | from test.support import import_helper |
20 | 22 | from test.support import force_not_colorized |
| 23 | +from test.support import SHORT_TIMEOUT |
21 | 24 | try: |
22 | 25 | from test.support import interpreters |
23 | 26 | except ImportError: |
@@ -1923,5 +1926,193 @@ def write(self, s): |
1923 | 1926 | self.assertEqual(out, b"") |
1924 | 1927 | self.assertEqual(err, b"") |
1925 | 1928 |
|
| 1929 | + |
| 1930 | +def _supports_remote_attaching(): |
| 1931 | + PROCESS_VM_READV_SUPPORTED = False |
| 1932 | + |
| 1933 | + try: |
| 1934 | + from _testexternalinspection import PROCESS_VM_READV_SUPPORTED |
| 1935 | + except ImportError: |
| 1936 | + pass |
| 1937 | + |
| 1938 | + return PROCESS_VM_READV_SUPPORTED |
| 1939 | + |
| 1940 | +@unittest.skipIf(not sys.is_remote_debug_enabled(), "Remote debugging is not enabled") |
| 1941 | +@unittest.skipIf(sys.platform != "darwin" and sys.platform != "linux", |
| 1942 | + "Test only runs on Linux and MacOS") |
| 1943 | +@unittest.skipIf(sys.platform == "linux" and not _supports_remote_attaching(), |
| 1944 | + "Test only runs on Linux with process_vm_readv support") |
| 1945 | +class TestRemoteExec(unittest.TestCase): |
| 1946 | + def tearDown(self): |
| 1947 | + test.support.reap_children() |
| 1948 | + |
| 1949 | + def _run_remote_exec_test(self, script_code, python_args=None, env=None, prologue=''): |
| 1950 | + # Create the script that will be remotely executed |
| 1951 | + script = os_helper.TESTFN + '_remote.py' |
| 1952 | + self.addCleanup(os_helper.unlink, script) |
| 1953 | + |
| 1954 | + with open(script, 'w') as f: |
| 1955 | + f.write(script_code) |
| 1956 | + |
| 1957 | + # Create and run the target process |
| 1958 | + target = os_helper.TESTFN + '_target.py' |
| 1959 | + self.addCleanup(os_helper.unlink, target) |
| 1960 | + |
| 1961 | + with os_helper.temp_dir() as work_dir: |
| 1962 | + fifo = f"{work_dir}/the_fifo" |
| 1963 | + os.mkfifo(fifo) |
| 1964 | + self.addCleanup(os_helper.unlink, fifo) |
| 1965 | + |
| 1966 | + with open(target, 'w') as f: |
| 1967 | + f.write(f''' |
| 1968 | +import sys |
| 1969 | +import time |
| 1970 | +
|
| 1971 | +with open("{fifo}", "w") as fifo: |
| 1972 | + fifo.write("ready") |
| 1973 | +
|
| 1974 | +{prologue} |
| 1975 | +
|
| 1976 | +print("Target process running...") |
| 1977 | +
|
| 1978 | +# Wait for remote script to be executed |
| 1979 | +# (the execution will happen as the following |
| 1980 | +# code is processed as soon as the read() call |
| 1981 | +# unblocks) |
| 1982 | +with open("{fifo}", "r") as fifo: |
| 1983 | + fifo.read() |
| 1984 | +
|
| 1985 | +# Write confirmation back |
| 1986 | +with open("{fifo}", "w") as fifo: |
| 1987 | + fifo.write("executed") |
| 1988 | +''') |
| 1989 | + |
| 1990 | + # Start the target process and capture its output |
| 1991 | + cmd = [sys.executable] |
| 1992 | + if python_args: |
| 1993 | + cmd.extend(python_args) |
| 1994 | + cmd.append(target) |
| 1995 | + |
| 1996 | + with subprocess.Popen(cmd, |
| 1997 | + stdout=subprocess.PIPE, |
| 1998 | + stderr=subprocess.PIPE, |
| 1999 | + env=env) as proc: |
| 2000 | + try: |
| 2001 | + # Wait for process to be ready |
| 2002 | + with open(fifo, "r") as fifo_file: |
| 2003 | + response = fifo_file.read() |
| 2004 | + self.assertEqual(response, "ready") |
| 2005 | + |
| 2006 | + # Try remote exec on the target process |
| 2007 | + sys.remote_exec(proc.pid, script) |
| 2008 | + |
| 2009 | + # Signal script to continue |
| 2010 | + with open(fifo, "w") as fifo_file: |
| 2011 | + fifo_file.write("continue") |
| 2012 | + |
| 2013 | + # Wait for execution confirmation |
| 2014 | + with open(fifo, "r") as fifo_file: |
| 2015 | + response = fifo_file.read() |
| 2016 | + self.assertEqual(response, "executed") |
| 2017 | + |
| 2018 | + # Return output for test verification |
| 2019 | + stdout, stderr = proc.communicate(timeout=1.0) |
| 2020 | + return proc.returncode, stdout, stderr |
| 2021 | + except PermissionError: |
| 2022 | + self.skipTest("Insufficient permissions to execute code in remote process") |
| 2023 | + finally: |
| 2024 | + proc.kill() |
| 2025 | + proc.terminate() |
| 2026 | + proc.wait(timeout=SHORT_TIMEOUT) |
| 2027 | + |
| 2028 | + def test_remote_exec(self): |
| 2029 | + """Test basic remote exec functionality""" |
| 2030 | + script = ''' |
| 2031 | +print("Remote script executed successfully!") |
| 2032 | +''' |
| 2033 | + returncode, stdout, stderr = self._run_remote_exec_test(script) |
| 2034 | + self.assertEqual(returncode, 0) |
| 2035 | + self.assertIn(b"Remote script executed successfully!", stdout) |
| 2036 | + self.assertEqual(stderr, b"") |
| 2037 | + |
| 2038 | + def test_remote_exec_with_self_process(self): |
| 2039 | + """Test remote exec with the target process being the same as the test process""" |
| 2040 | + |
| 2041 | + code = 'import sys;print("Remote script executed successfully!", file=sys.stderr)' |
| 2042 | + file = os_helper.TESTFN + '_remote.py' |
| 2043 | + with open(file, 'w') as f: |
| 2044 | + f.write(code) |
| 2045 | + self.addCleanup(os_helper.unlink, file) |
| 2046 | + with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: |
| 2047 | + with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: |
| 2048 | + sys.remote_exec(os.getpid(), file) |
| 2049 | + print("Done") |
| 2050 | + self.assertEqual(mock_stderr.getvalue(), "Remote script executed successfully!\n") |
| 2051 | + self.assertEqual(mock_stdout.getvalue(), "Done\n") |
| 2052 | + |
| 2053 | + def test_remote_exec_raises_audit_event(self): |
| 2054 | + """Test remote exec raises an audit event""" |
| 2055 | + prologue = '''\ |
| 2056 | +import sys |
| 2057 | +def audit_hook(event, arg): |
| 2058 | + print(f"Audit event: {event}, arg: {arg}") |
| 2059 | +sys.addaudithook(audit_hook) |
| 2060 | +''' |
| 2061 | + script = ''' |
| 2062 | +print("Remote script executed successfully!") |
| 2063 | +''' |
| 2064 | + returncode, stdout, stderr = self._run_remote_exec_test(script, prologue=prologue) |
| 2065 | + self.assertEqual(returncode, 0) |
| 2066 | + self.assertIn(b"Remote script executed successfully!", stdout) |
| 2067 | + self.assertIn(b"Audit event: remote_debugger_script, arg: ", stdout) |
| 2068 | + self.assertEqual(stderr, b"") |
| 2069 | + |
| 2070 | + def test_remote_exec_with_exception(self): |
| 2071 | + """Test remote exec with an exception raised in the target process |
| 2072 | +
|
| 2073 | + The exception should be raised in the main thread of the target process |
| 2074 | + but not crash the target process. |
| 2075 | + """ |
| 2076 | + script = ''' |
| 2077 | +raise Exception("Remote script exception") |
| 2078 | +''' |
| 2079 | + returncode, stdout, stderr = self._run_remote_exec_test(script) |
| 2080 | + self.assertEqual(returncode, 0) |
| 2081 | + self.assertIn(b"Remote script exception", stderr) |
| 2082 | + self.assertEqual(stdout, b"Target process running...\n") |
| 2083 | + |
| 2084 | + def test_remote_exec_disabled_by_env(self): |
| 2085 | + """Test remote exec is disabled when PYTHON_DISABLE_REMOTE_DEBUG is set""" |
| 2086 | + env = os.environ.copy() |
| 2087 | + env['PYTHON_DISABLE_REMOTE_DEBUG'] = '1' |
| 2088 | + with self.assertRaisesRegex(RuntimeError, "Remote debugging is not enabled in the remote process"): |
| 2089 | + self._run_remote_exec_test("print('should not run')", env=env) |
| 2090 | + |
| 2091 | + def test_remote_exec_disabled_by_xoption(self): |
| 2092 | + """Test remote exec is disabled with -Xdisable-remote-debug""" |
| 2093 | + with self.assertRaisesRegex(RuntimeError, "Remote debugging is not enabled in the remote process"): |
| 2094 | + self._run_remote_exec_test("print('should not run')", python_args=['-Xdisable-remote-debug']) |
| 2095 | + |
| 2096 | + def test_remote_exec_invalid_pid(self): |
| 2097 | + """Test remote exec with invalid process ID""" |
| 2098 | + with self.assertRaises(OSError): |
| 2099 | + sys.remote_exec(999999, "print('should not run')") |
| 2100 | + |
| 2101 | + def test_remote_exec_syntax_error(self): |
| 2102 | + """Test remote exec with syntax error in script""" |
| 2103 | + script = ''' |
| 2104 | +this is invalid python code |
| 2105 | +''' |
| 2106 | + returncode, stdout, stderr = self._run_remote_exec_test(script) |
| 2107 | + self.assertEqual(returncode, 0) |
| 2108 | + self.assertIn(b"SyntaxError", stderr) |
| 2109 | + self.assertEqual(stdout, b"Target process running...\n") |
| 2110 | + |
| 2111 | + def test_remote_exec_invalid_script_path(self): |
| 2112 | + """Test remote exec with invalid script path""" |
| 2113 | + with self.assertRaises(OSError): |
| 2114 | + sys.remote_exec(os.getpid(), "invalid_script_path") |
| 2115 | + |
| 2116 | + |
1926 | 2117 | if __name__ == "__main__": |
1927 | 2118 | unittest.main() |
0 commit comments