From b90530cb872846daac876e076769c30cac301cb1 Mon Sep 17 00:00:00 2001 From: CPython Developers <> Date: Mon, 2 Feb 2026 01:33:16 +0900 Subject: [PATCH 1/2] Update test_faulthandler from v3.14.2 --- Lib/test/test_faulthandler.py | 172 +++++++++++++++++++--------------- 1 file changed, 95 insertions(+), 77 deletions(-) diff --git a/Lib/test/test_faulthandler.py b/Lib/test/test_faulthandler.py index 5596e5b1669..c98152c502f 100644 --- a/Lib/test/test_faulthandler.py +++ b/Lib/test/test_faulthandler.py @@ -22,6 +22,16 @@ TIMEOUT = 0.5 +STACK_HEADER_STR = r'Stack (most recent call first):' + +# Regular expressions +STACK_HEADER = re.escape(STACK_HEADER_STR) +THREAD_NAME = r'( \[.*\])?' +THREAD_ID = fr'Thread 0x[0-9a-f]+{THREAD_NAME}' +THREAD_HEADER = fr'{THREAD_ID} \(most recent call first\):' +CURRENT_THREAD_ID = fr'Current thread 0x[0-9a-f]+{THREAD_NAME}' +CURRENT_THREAD_HEADER = fr'{CURRENT_THREAD_ID} \(most recent call first\):' + def expected_traceback(lineno1, lineno2, header, min_count=1): regex = header @@ -45,6 +55,13 @@ def temporary_filename(): finally: os_helper.unlink(filename) + +ADDRESS_EXPR = "0x[0-9a-f]+" +C_STACK_REGEX = [ + r"Current thread's C stack trace \(most recent call first\):", + fr'( Binary file ".+"(, at .*(\+|-){ADDRESS_EXPR})? \[{ADDRESS_EXPR}\])|(<.+>)' +] + class FaultHandlerTests(unittest.TestCase): def get_output(self, code, filename=None, fd=None): @@ -93,6 +110,7 @@ def check_error(self, code, lineno, fatal_error, *, fd=None, know_current_thread=True, py_fatal_error=False, garbage_collecting=False, + c_stack=True, function=''): """ Check that the fault handler for fatal errors is enabled and check the @@ -100,21 +118,32 @@ def check_error(self, code, lineno, fatal_error, *, Raise an error if the output doesn't match the expected format. """ - if all_threads: + all_threads_disabled = ( + all_threads + and (not sys._is_gil_enabled()) + ) + if all_threads and not all_threads_disabled: if know_current_thread: - header = 'Current thread 0x[0-9a-f]+' + header = CURRENT_THREAD_HEADER else: - header = 'Thread 0x[0-9a-f]+' + header = THREAD_HEADER else: - header = 'Stack' + header = STACK_HEADER regex = [f'^{fatal_error}'] if py_fatal_error: regex.append("Python runtime state: initialized") regex.append('') - regex.append(fr'{header} \(most recent call first\):') - if garbage_collecting: - regex.append(' Garbage-collecting') - regex.append(fr' File "", line {lineno} in {function}') + if all_threads_disabled and not py_fatal_error: + regex.append("") + regex.append(fr'{header}') + if support.Py_GIL_DISABLED and py_fatal_error and not know_current_thread: + regex.append(" ") + else: + if garbage_collecting and not all_threads_disabled: + regex.append(' Garbage-collecting') + regex.append(fr' File "", line {lineno} in {function}') + if c_stack: + regex.extend(C_STACK_REGEX) regex = '\n'.join(regex) if other_regex: @@ -137,8 +166,6 @@ def check_windows_exception(self, code, line_number, name_regex, **kw): fatal_error = 'Windows fatal exception: %s' % name_regex self.check_error(code, line_number, fatal_error, **kw) - # TODO: RUSTPYTHON - @unittest.expectedFailure @unittest.skipIf(sys.platform.startswith('aix'), "the first page of memory is a mapped read-only on AIX") def test_read_null(self): @@ -162,8 +189,6 @@ def test_read_null(self): 3, 'access violation') - # TODO: RUSTPYTHON, AssertionError: Regex didn't match - @unittest.expectedFailure @skip_segfault_on_android def test_sigsegv(self): self.check_fatal_error(""" @@ -174,8 +199,7 @@ def test_sigsegv(self): 3, 'Segmentation fault') - # TODO: RUSTPYTHON - @unittest.expectedFailure + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Regex didn't match: '(?m)^Fatal Python error: Segmentation fault\n\n\nStack\\ \\(most\\ recent\\ call\\ first\\):\n File "", line 9 in __del__\nCurrent thread\'s C stack trace \\(most recent call first\\):\n( Binary file ".+"(, at .*(\\+|-)0x[0-9a-f]+)? \\[0x[0-9a-f]+\\])|(<.+>)' not found in 'exit' @skip_segfault_on_android def test_gc(self): # bpo-44466: Detect if the GC is running @@ -212,8 +236,7 @@ def __del__(self): function='__del__', garbage_collecting=True) - # TODO: RUSTPYTHON - @unittest.expectedFailure + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 0 == 0 def test_fatal_error_c_thread(self): self.check_fatal_error(""" import faulthandler @@ -226,8 +249,7 @@ def test_fatal_error_c_thread(self): func='faulthandler_fatal_error_thread', py_fatal_error=True) - # TODO: RUSTPYTHON - @unittest.expectedFailure + @support.skip_if_sanitizer("TSAN itercepts SIGABRT", thread=True) def test_sigabrt(self): self.check_fatal_error(""" import faulthandler @@ -237,10 +259,9 @@ def test_sigabrt(self): 3, 'Aborted') - # TODO: RUSTPYTHON - @unittest.expectedFailure @unittest.skipIf(sys.platform == 'win32', "SIGFPE cannot be caught on Windows") + @support.skip_if_sanitizer("TSAN itercepts SIGFPE", thread=True) def test_sigfpe(self): self.check_fatal_error(""" import faulthandler @@ -252,6 +273,7 @@ def test_sigfpe(self): @unittest.skipIf(_testcapi is None, 'need _testcapi') @unittest.skipUnless(hasattr(signal, 'SIGBUS'), 'need signal.SIGBUS') + @support.skip_if_sanitizer("TSAN itercepts SIGBUS", thread=True) @skip_segfault_on_android def test_sigbus(self): self.check_fatal_error(""" @@ -266,6 +288,7 @@ def test_sigbus(self): @unittest.skipIf(_testcapi is None, 'need _testcapi') @unittest.skipUnless(hasattr(signal, 'SIGILL'), 'need signal.SIGILL') + @support.skip_if_sanitizer("TSAN itercepts SIGILL", thread=True) @skip_segfault_on_android def test_sigill(self): self.check_fatal_error(""" @@ -291,13 +314,9 @@ def check_fatal_error_func(self, release_gil): func='_testcapi_fatal_error_impl', py_fatal_error=True) - # TODO: RUSTPYTHON - @unittest.expectedFailure def test_fatal_error(self): self.check_fatal_error_func(False) - # TODO: RUSTPYTHON - @unittest.expectedFailure def test_fatal_error_without_gil(self): self.check_fatal_error_func(True) @@ -316,8 +335,6 @@ def test_stack_overflow(self): '(?:Segmentation fault|Bus error)', other_regex='unable to raise a stack overflow') - # TODO: RUSTPYTHON - @unittest.expectedFailure @skip_segfault_on_android def test_gil_released(self): self.check_fatal_error(""" @@ -328,8 +345,6 @@ def test_gil_released(self): 3, 'Segmentation fault') - # TODO: RUSTPYTHON - @unittest.expectedFailure @skip_segfault_on_android def test_enable_file(self): with temporary_filename() as filename: @@ -343,8 +358,6 @@ def test_enable_file(self): 'Segmentation fault', filename=filename) - # TODO: RUSTPYTHON - @unittest.expectedFailure @unittest.skipIf(sys.platform == "win32", "subprocess doesn't support pass_fds on Windows") @skip_segfault_on_android @@ -361,8 +374,6 @@ def test_enable_fd(self): 'Segmentation fault', fd=fd) - # TODO: RUSTPYTHON - @unittest.expectedFailure @skip_segfault_on_android def test_enable_single_thread(self): self.check_fatal_error(""" @@ -389,8 +400,7 @@ def test_disable(self): "%r is present in %r" % (not_expected, stderr)) self.assertNotEqual(exitcode, 0) - # TODO: RUSTPYTHON - @unittest.expectedFailure + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Cannot find 'Extension modules:' in 'Fatal Python error: Segmentation fault\n\nCurrent thread 0x0000000000004284 (most recent call first):\n File "", line 6 in ' @skip_segfault_on_android def test_dump_ext_modules(self): code = """ @@ -511,7 +521,7 @@ def funcA(): else: lineno = 14 expected = [ - 'Stack (most recent call first):', + f'{STACK_HEADER_STR}', ' File "", line %s in funcB' % lineno, ' File "", line 17 in funcA', ' File "", line 19 in ' @@ -523,14 +533,11 @@ def funcA(): def test_dump_traceback(self): self.check_dump_traceback() - # TODO: RUSTPYTHON - binary file write needs different handling - @unittest.expectedFailure + @unittest.expectedFailure # TODO: RUSTPYTHON; - binary file write needs different handling def test_dump_traceback_file(self): with temporary_filename() as filename: self.check_dump_traceback(filename=filename) - # TODO: RUSTPYTHON - @unittest.expectedFailure @unittest.skipIf(sys.platform == "win32", "subprocess doesn't support pass_fds on Windows") def test_dump_traceback_fd(self): @@ -553,7 +560,7 @@ def {func_name}(): func_name=func_name, ) expected = [ - 'Stack (most recent call first):', + f'{STACK_HEADER_STR}', ' File "", line 4 in %s' % truncated, ' File "", line 6 in ' ] @@ -607,28 +614,26 @@ def run(self): lineno = 10 # When the traceback is dumped, the waiter thread may be in the # `self.running.set()` call or in `self.stop.wait()`. - regex = r""" - ^Thread 0x[0-9a-f]+ \(most recent call first\): + regex = fr""" + ^{THREAD_HEADER} (?: File ".*threading.py", line [0-9]+ in [_a-z]+ ){{1,3}} File "", line (?:22|23) in run File ".*threading.py", line [0-9]+ in _bootstrap_inner File ".*threading.py", line [0-9]+ in _bootstrap - Current thread 0x[0-9a-f]+ \(most recent call first\): + {CURRENT_THREAD_HEADER} File "", line {lineno} in dump File "", line 28 in $ """ - regex = dedent(regex.format(lineno=lineno)).strip() + regex = dedent(regex).strip() self.assertRegex(output, regex) self.assertEqual(exitcode, 0) - # TODO: RUSTPYTHON - @unittest.expectedFailure + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Regex didn't match: '^Thread 0x[0-9a-f]+( \\[.*\\])? \\(most recent call first\\):\n(?: File ".*threading.py", line [0-9]+ in [_a-z]+\n){1,3} File "", line (?:22|23) in run\n File ".*threading.py", line [0-9]+ in _bootstrap_inner\n File ".*threading.py", line [0-9]+ in _bootstrap\n\nCurrent thread 0x[0-9a-f]+( \\[.*\\])? \\(most recent call first\\):\n File "", line 10 in dump\n File "", line 28 in $' not found in 'Stack (most recent call first):\n File "", line 10 in dump\n File "", line 28 in ' def test_dump_traceback_threads(self): self.check_dump_traceback_threads(None) - # TODO: RUSTPYTHON - @unittest.expectedFailure + @unittest.expectedFailure # TODO: RUSTPYTHON; - TypeError: a bytes-like object is required, not 'str' def test_dump_traceback_threads_file(self): with temporary_filename() as filename: self.check_dump_traceback_threads(filename) @@ -688,44 +693,38 @@ def func(timeout, repeat, cancel, file, loops): count = loops if repeat: count *= 2 - header = r'Timeout \(%s\)!\nThread 0x[0-9a-f]+ \(most recent call first\):\n' % timeout_str + header = (fr'Timeout \({timeout_str}\)!\n' + fr'{THREAD_HEADER}\n') regex = expected_traceback(17, 26, header, min_count=count) self.assertRegex(trace, regex) else: self.assertEqual(trace, '') self.assertEqual(exitcode, 0) - # TODO: RUSTPYTHON - @unittest.expectedFailure + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Regex didn't match: '^Timeout \\(0:00:00.500000\\)!\\nThread 0x[0-9a-f]+( \\[.*\\])? \\(most recent call first\\):\\n File "", line 17 in func\n File "", line 26 in $' not found in 'Traceback (most recent call last):\n File "", line 26, in \n File "", line 14, in func\nAttributeError: \'NoneType\' object has no attribute \'fileno\'' def test_dump_traceback_later(self): self.check_dump_traceback_later() - # TODO: RUSTPYTHON - @unittest.expectedFailure + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Regex didn't match: '^Timeout \\(0:00:00.500000\\)!\\nThread 0x[0-9a-f]+( \\[.*\\])? \\(most recent call first\\):\\n File "", line 17 in func\n File "", line 26 in \nTimeout \\(0:00:00.500000\\)!\\nThread 0x[0-9a-f]+( \\[.*\\])? \\(most recent call first\\):\\n File "", line 17 in func\n File "", line 26 in ' not found in 'Traceback (most recent call last):\n File "", line 26, in \n File "", line 14, in func\nAttributeError: \'NoneType\' object has no attribute \'fileno\'' def test_dump_traceback_later_repeat(self): self.check_dump_traceback_later(repeat=True) - # TODO: RUSTPYTHON - @unittest.expectedFailure + @unittest.expectedFailure # TODO: RUSTPYTHON; - AttributeError: 'NoneType' object has no attribute 'fileno' def test_dump_traceback_later_cancel(self): self.check_dump_traceback_later(cancel=True) - # TODO: RUSTPYTHON - @unittest.expectedFailure + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Regex didn't match: '^Timeout \\(0:00:00.500000\\)!\\nThread 0x[0-9a-f]+( \\[.*\\])? \\(most recent call first\\):\\n File "", line 17 in func\n File "", line 26 in $' not found in 'Timeout (00:00:00.500000)!\n' def test_dump_traceback_later_file(self): with temporary_filename() as filename: self.check_dump_traceback_later(filename=filename) - # TODO: RUSTPYTHON - @unittest.expectedFailure @unittest.skipIf(sys.platform == "win32", "subprocess doesn't support pass_fds on Windows") def test_dump_traceback_later_fd(self): with tempfile.TemporaryFile('wb+') as fp: self.check_dump_traceback_later(fd=fp.fileno()) - # TODO: RUSTPYTHON - @unittest.expectedFailure + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Regex didn't match: '^Timeout \\(0:00:00.500000\\)!\\nThread 0x[0-9a-f]+( \\[.*\\])? \\(most recent call first\\):\\n File "", line 17 in func\n File "", line 26 in \nTimeout \\(0:00:00.500000\\)!\\nThread 0x[0-9a-f]+( \\[.*\\])? \\(most recent call first\\):\\n File "", line 17 in func\n File "", line 26 in ' not found in 'Traceback (most recent call last):\n File "", line 26, in \n File "", line 14, in func\nAttributeError: \'NoneType\' object has no attribute \'fileno\'' @support.requires_resource('walltime') def test_dump_traceback_later_twice(self): self.check_dump_traceback_later(loops=2) @@ -801,9 +800,9 @@ def handler(signum, frame): trace = '\n'.join(trace) if not unregister: if all_threads: - regex = r'Current thread 0x[0-9a-f]+ \(most recent call first\):\n' + regex = fr'{CURRENT_THREAD_HEADER}\n' else: - regex = r'Stack \(most recent call first\):\n' + regex = fr'{STACK_HEADER}\n' regex = expected_traceback(14, 32, regex) self.assertRegex(trace, regex) else: @@ -813,37 +812,26 @@ def handler(signum, frame): else: self.assertEqual(exitcode, 0) - # TODO: RUSTPYTHON - @unittest.expectedFailure def test_register(self): self.check_register() - # TODO: RUSTPYTHON - @unittest.expectedFailure def test_unregister(self): self.check_register(unregister=True) - # TODO: RUSTPYTHON - @unittest.expectedFailure def test_register_file(self): with temporary_filename() as filename: self.check_register(filename=filename) - # TODO: RUSTPYTHON - @unittest.expectedFailure @unittest.skipIf(sys.platform == "win32", "subprocess doesn't support pass_fds on Windows") def test_register_fd(self): with tempfile.TemporaryFile('wb+') as fp: self.check_register(fd=fp.fileno()) - # TODO: RUSTPYTHON - @unittest.expectedFailure def test_register_threads(self): self.check_register(all_threads=True) - # TODO: RUSTPYTHON - @unittest.expectedFailure + @support.skip_if_sanitizer("gh-129825: hangs under TSAN", thread=True) def test_register_chain(self): self.check_register(chain=True) @@ -871,8 +859,6 @@ def test_stderr_None(self): with self.check_stderr_none(): faulthandler.register(signal.SIGUSR1) - # TODO: RUSTPYTHON, AttributeError: module 'msvcrt' has no attribute 'GetErrorMode' - @unittest.expectedFailure @unittest.skipUnless(MS_WINDOWS, 'specific to Windows') def test_raise_exception(self): for exc, name in ( @@ -985,5 +971,37 @@ def run(self): _, exitcode = self.get_output(code) self.assertEqual(exitcode, 0) + def check_c_stack(self, output): + starting_line = output.pop(0) + self.assertRegex(starting_line, C_STACK_REGEX[0]) + self.assertGreater(len(output), 0) + + for line in output: + with self.subTest(line=line): + if line != '': # Ignore trailing or leading newlines + self.assertRegex(line, C_STACK_REGEX[1]) + + + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 1 != 0 + def test_dump_c_stack(self): + code = dedent(""" + import faulthandler + faulthandler.dump_c_stack() + """) + output, exitcode = self.get_output(code) + self.assertEqual(exitcode, 0) + self.check_c_stack(output) + + + @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: module 'faulthandler' has no attribute 'dump_c_stack' + def test_dump_c_stack_file(self): + import tempfile + + with tempfile.TemporaryFile("w+") as tmp: + faulthandler.dump_c_stack(file=tmp) + tmp.flush() # Just in case + tmp.seek(0) + self.check_c_stack(tmp.read().split("\n")) + if __name__ == "__main__": unittest.main() From cdadde55efcb29550856d94b095fb0f3166195db Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" Date: Mon, 2 Feb 2026 01:32:58 +0900 Subject: [PATCH 2/2] Update faulthandler to match CPython 3.14.2 - Rewrite faulthandler with live frame walking via Frame.previous AtomicPtr chain and thread-local CURRENT_FRAME (AtomicPtr) instead of frame snapshots - Add signal-safe traceback dumping (dump_live_frames, dump_frame_from_raw) walking the Frame.previous chain - Add safe_truncate/dump_ascii for UTF-8 safe string truncation in signal handlers - Refactor write_thread_id to accept thread_id parameter - Add SA_RESTART for user signal registration, SA_NODEFER only when chaining - Save/restore errno in faulthandler_user_signal - Add signal re-entrancy guard in trigger_signals to prevent recursive handler invocation - Add thread frame tracking (push/pop/cleanup/reinit) with force_unlock fallback for post-fork recovery - Remove expectedFailure markers for now-passing tests --- Lib/test/test_faulthandler.py | 8 - Lib/test/test_inspect/test_inspect.py | 1 - Lib/test/test_listcomps.py | 2 - Lib/test/test_setcomps.py | 1 - Lib/test/test_traceback.py | 4 - crates/codegen/src/compile.rs | 19 +- crates/stdlib/src/faulthandler.rs | 655 +++++++++++++++----------- crates/vm/src/frame.rs | 11 +- crates/vm/src/signal.rs | 22 + crates/vm/src/stdlib/thread.rs | 7 +- crates/vm/src/vm/mod.rs | 17 +- crates/vm/src/vm/thread.rs | 76 ++- 12 files changed, 516 insertions(+), 307 deletions(-) diff --git a/Lib/test/test_faulthandler.py b/Lib/test/test_faulthandler.py index c98152c502f..090fb3a1484 100644 --- a/Lib/test/test_faulthandler.py +++ b/Lib/test/test_faulthandler.py @@ -533,7 +533,6 @@ def funcA(): def test_dump_traceback(self): self.check_dump_traceback() - @unittest.expectedFailure # TODO: RUSTPYTHON; - binary file write needs different handling def test_dump_traceback_file(self): with temporary_filename() as filename: self.check_dump_traceback(filename=filename) @@ -629,11 +628,9 @@ def run(self): self.assertRegex(output, regex) self.assertEqual(exitcode, 0) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Regex didn't match: '^Thread 0x[0-9a-f]+( \\[.*\\])? \\(most recent call first\\):\n(?: File ".*threading.py", line [0-9]+ in [_a-z]+\n){1,3} File "", line (?:22|23) in run\n File ".*threading.py", line [0-9]+ in _bootstrap_inner\n File ".*threading.py", line [0-9]+ in _bootstrap\n\nCurrent thread 0x[0-9a-f]+( \\[.*\\])? \\(most recent call first\\):\n File "", line 10 in dump\n File "", line 28 in $' not found in 'Stack (most recent call first):\n File "", line 10 in dump\n File "", line 28 in ' def test_dump_traceback_threads(self): self.check_dump_traceback_threads(None) - @unittest.expectedFailure # TODO: RUSTPYTHON; - TypeError: a bytes-like object is required, not 'str' def test_dump_traceback_threads_file(self): with temporary_filename() as filename: self.check_dump_traceback_threads(filename) @@ -701,19 +698,15 @@ def func(timeout, repeat, cancel, file, loops): self.assertEqual(trace, '') self.assertEqual(exitcode, 0) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Regex didn't match: '^Timeout \\(0:00:00.500000\\)!\\nThread 0x[0-9a-f]+( \\[.*\\])? \\(most recent call first\\):\\n File "", line 17 in func\n File "", line 26 in $' not found in 'Traceback (most recent call last):\n File "", line 26, in \n File "", line 14, in func\nAttributeError: \'NoneType\' object has no attribute \'fileno\'' def test_dump_traceback_later(self): self.check_dump_traceback_later() - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Regex didn't match: '^Timeout \\(0:00:00.500000\\)!\\nThread 0x[0-9a-f]+( \\[.*\\])? \\(most recent call first\\):\\n File "", line 17 in func\n File "", line 26 in \nTimeout \\(0:00:00.500000\\)!\\nThread 0x[0-9a-f]+( \\[.*\\])? \\(most recent call first\\):\\n File "", line 17 in func\n File "", line 26 in ' not found in 'Traceback (most recent call last):\n File "", line 26, in \n File "", line 14, in func\nAttributeError: \'NoneType\' object has no attribute \'fileno\'' def test_dump_traceback_later_repeat(self): self.check_dump_traceback_later(repeat=True) - @unittest.expectedFailure # TODO: RUSTPYTHON; - AttributeError: 'NoneType' object has no attribute 'fileno' def test_dump_traceback_later_cancel(self): self.check_dump_traceback_later(cancel=True) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Regex didn't match: '^Timeout \\(0:00:00.500000\\)!\\nThread 0x[0-9a-f]+( \\[.*\\])? \\(most recent call first\\):\\n File "", line 17 in func\n File "", line 26 in $' not found in 'Timeout (00:00:00.500000)!\n' def test_dump_traceback_later_file(self): with temporary_filename() as filename: self.check_dump_traceback_later(filename=filename) @@ -724,7 +717,6 @@ def test_dump_traceback_later_fd(self): with tempfile.TemporaryFile('wb+') as fp: self.check_dump_traceback_later(fd=fp.fileno()) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Regex didn't match: '^Timeout \\(0:00:00.500000\\)!\\nThread 0x[0-9a-f]+( \\[.*\\])? \\(most recent call first\\):\\n File "", line 17 in func\n File "", line 26 in \nTimeout \\(0:00:00.500000\\)!\\nThread 0x[0-9a-f]+( \\[.*\\])? \\(most recent call first\\):\\n File "", line 17 in func\n File "", line 26 in ' not found in 'Traceback (most recent call last):\n File "", line 26, in \n File "", line 14, in func\nAttributeError: \'NoneType\' object has no attribute \'fileno\'' @support.requires_resource('walltime') def test_dump_traceback_later_twice(self): self.check_dump_traceback_later(loops=2) diff --git a/Lib/test/test_inspect/test_inspect.py b/Lib/test/test_inspect/test_inspect.py index 13ae2e3cb9c..c2d64813ad7 100644 --- a/Lib/test/test_inspect/test_inspect.py +++ b/Lib/test/test_inspect/test_inspect.py @@ -540,7 +540,6 @@ def test_abuse_done(self): self.istest(inspect.istraceback, 'git.ex.__traceback__') self.istest(inspect.isframe, 'mod.fr') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_stack(self): self.assertTrue(len(mod.st) >= 5) frame1, frame2, frame3, frame4, *_ = mod.st diff --git a/Lib/test/test_listcomps.py b/Lib/test/test_listcomps.py index 1380c08d28b..6c1701dc9a5 100644 --- a/Lib/test/test_listcomps.py +++ b/Lib/test/test_listcomps.py @@ -716,8 +716,6 @@ def test_multiple_comprehension_name_reuse(self): self._check_in_scopes(code, {"x": 2, "y": [3]}, ns={"x": 3}, scopes=["class"]) self._check_in_scopes(code, {"x": 2, "y": [2]}, ns={"x": 3}, scopes=["function", "module"]) - # TODO: RUSTPYTHON - @unittest.expectedFailure def test_exception_locations(self): # The location of an exception raised from __init__ or # __next__ should should be the iterator expression diff --git a/Lib/test/test_setcomps.py b/Lib/test/test_setcomps.py index e8c0c33e980..0bb02ef11f6 100644 --- a/Lib/test/test_setcomps.py +++ b/Lib/test/test_setcomps.py @@ -152,7 +152,6 @@ """ class SetComprehensionTest(unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'FrameSummary' object has no attribute 'end_lineno' def test_exception_locations(self): # The location of an exception raised from __init__ or # __next__ should should be the iterator expression diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 2ba7fbda5c3..7d6f5de95a8 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -3423,8 +3423,6 @@ def test_no_locals(self): s = traceback.StackSummary.extract(iter([(f, 6)])) self.assertEqual(s[0].locals, None) - # TODO: RUSTPYTHON - @unittest.expectedFailure def test_format_locals(self): def some_inner(k, v): a = 1 @@ -3441,8 +3439,6 @@ def some_inner(k, v): ' v = 4\n' % (__file__, some_inner.__code__.co_firstlineno + 3) ], s.format()) - # TODO: RUSTPYTHON - @unittest.expectedFailure def test_custom_format_frame(self): class CustomStackSummary(traceback.StackSummary): def format_frame_summary(self, frame_summary, colorize=False): diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index 9b59d9da8c7..02167667a8b 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -4638,7 +4638,7 @@ impl Compiler { self.emit_load_const(ConstantData::Str { value: name.into() }); if let Some(arguments) = arguments { - self.codegen_call_helper(2, arguments)?; + self.codegen_call_helper(2, arguments, self.current_source_range)?; } else { emit!(self, Instruction::Call { nargs: 2 }); } @@ -7079,6 +7079,10 @@ impl Compiler { } fn compile_call(&mut self, func: &ast::Expr, args: &ast::Arguments) -> CompileResult<()> { + // Save the call expression's source range so CALL instructions use the + // call start line, not the last argument's line. + let call_range = self.current_source_range; + // Method call: obj → LOAD_ATTR_METHOD → [method, self_or_null] → args → CALL // Regular call: func → PUSH_NULL → args → CALL if let ast::Expr::Attribute(ast::ExprAttribute { value, attr, .. }) = &func { @@ -7096,21 +7100,21 @@ impl Compiler { self.emit_load_zero_super_method(idx); } } - self.codegen_call_helper(0, args)?; + self.codegen_call_helper(0, args, call_range)?; } else { // Normal method call: compile object, then LOAD_ATTR with method flag // LOAD_ATTR(method=1) pushes [method, self_or_null] on stack self.compile_expression(value)?; let idx = self.name(attr.as_str()); self.emit_load_attr_method(idx); - self.codegen_call_helper(0, args)?; + self.codegen_call_helper(0, args, call_range)?; } } else { // Regular call: push func, then NULL for self_or_null slot // Stack layout: [func, NULL, args...] - same as method call [func, self, args...] self.compile_expression(func)?; emit!(self, Instruction::PushNull); - self.codegen_call_helper(0, args)?; + self.codegen_call_helper(0, args, call_range)?; } Ok(()) } @@ -7152,10 +7156,13 @@ impl Compiler { } /// Compile call arguments and emit the appropriate CALL instruction. + /// `call_range` is the source range of the call expression, used to set + /// the correct line number on the CALL instruction. fn codegen_call_helper( &mut self, additional_positional: u32, arguments: &ast::Arguments, + call_range: TextRange, ) -> CompileResult<()> { let nelts = arguments.args.len(); let nkwelts = arguments.keywords.len(); @@ -7186,6 +7193,8 @@ impl Compiler { self.compile_expression(&keyword.value)?; } + // Restore call expression range for kwnames and CALL_KW + self.set_source_range(call_range); self.emit_load_const(ConstantData::Tuple { elements: kwarg_names, }); @@ -7193,6 +7202,7 @@ impl Compiler { let nargs = additional_positional + nelts.to_u32() + nkwelts.to_u32(); emit!(self, Instruction::CallKw { nargs }); } else { + self.set_source_range(call_range); let nargs = additional_positional + nelts.to_u32(); emit!(self, Instruction::Call { nargs }); } @@ -7284,6 +7294,7 @@ impl Compiler { emit!(self, Instruction::PushNull); } + self.set_source_range(call_range); emit!(self, Instruction::CallFunctionEx); } diff --git a/crates/stdlib/src/faulthandler.rs b/crates/stdlib/src/faulthandler.rs index 6a2a0933404..b8d7fe9f91b 100644 --- a/crates/stdlib/src/faulthandler.rs +++ b/crates/stdlib/src/faulthandler.rs @@ -7,7 +7,6 @@ mod decl { PyObjectRef, PyResult, VirtualMachine, frame::Frame, function::{ArgIntoFloat, OptionalArg}, - py_io::Write, }; use alloc::sync::Arc; use core::sync::atomic::{AtomicBool, AtomicI32, Ordering}; @@ -66,11 +65,7 @@ mod decl { #[cfg(windows)] const FAULTHANDLER_NSIGNALS: usize = 4; - // CPython uses static arrays for signal handlers which requires mutable static access. - // This is safe because: - // 1. Signal handlers run in a single-threaded context (from the OS perspective) - // 2. FAULTHANDLER_HANDLERS is only modified during enable/disable operations - // 3. This matches CPython's faulthandler.c implementation + // Signal handlers use mutable statics matching faulthandler.c implementation. #[cfg(unix)] static mut FAULTHANDLER_HANDLERS: [FaultHandler; FAULTHANDLER_NSIGNALS] = [ FaultHandler::new(libc::SIGBUS, "Bus error"), @@ -101,6 +96,10 @@ mod decl { all_threads: AtomicBool::new(true), }; + /// Arc>> - shared frame slot for a thread + #[cfg(feature = "threading")] + type ThreadFrameSlot = Arc>>; + // Watchdog thread state for dump_traceback_later struct WatchdogState { cancel: bool, @@ -109,51 +108,32 @@ mod decl { repeat: bool, exit: bool, header: String, + #[cfg(feature = "threading")] + thread_frame_slots: Vec<(u64, ThreadFrameSlot)>, } type WatchdogHandle = Arc<(Mutex, Condvar)>; static WATCHDOG: Mutex> = Mutex::new(None); - // Frame snapshot for signal-safe traceback (RustPython-specific) - - /// Frame information snapshot for signal-safe access - #[cfg(any(unix, windows))] - #[derive(Clone, Copy)] - struct FrameSnapshot { - filename: [u8; 256], - filename_len: usize, - lineno: u32, - funcname: [u8; 128], - funcname_len: usize, - } + // Signal-safe output functions + // PUTS macro #[cfg(any(unix, windows))] - impl FrameSnapshot { - const EMPTY: Self = Self { - filename: [0; 256], - filename_len: 0, - lineno: 0, - funcname: [0; 128], - funcname_len: 0, + fn puts(fd: i32, s: &str) { + let _ = unsafe { + #[cfg(windows)] + { + libc::write(fd, s.as_ptr() as *const libc::c_void, s.len() as u32) + } + #[cfg(not(windows))] + { + libc::write(fd, s.as_ptr() as *const libc::c_void, s.len()) + } }; } #[cfg(any(unix, windows))] - const MAX_SNAPSHOT_FRAMES: usize = 100; - - /// Signal-safe global storage for frame snapshots - #[cfg(any(unix, windows))] - static mut FRAME_SNAPSHOTS: [FrameSnapshot; MAX_SNAPSHOT_FRAMES] = - [FrameSnapshot::EMPTY; MAX_SNAPSHOT_FRAMES]; - #[cfg(any(unix, windows))] - static SNAPSHOT_COUNT: core::sync::atomic::AtomicUsize = - core::sync::atomic::AtomicUsize::new(0); - - // Signal-safe output functions - - // PUTS macro - #[cfg(any(unix, windows))] - fn puts(fd: i32, s: &str) { + fn puts_bytes(fd: i32, s: &[u8]) { let _ = unsafe { #[cfg(windows)] { @@ -235,59 +215,69 @@ mod decl { // write_thread_id (traceback.c:1240-1256) #[cfg(any(unix, windows))] - fn write_thread_id(fd: i32, is_current: bool) { + fn write_thread_id(fd: i32, thread_id: u64, is_current: bool) { if is_current { - puts(fd, "Current thread 0x"); + puts(fd, "Current thread "); } else { - puts(fd, "Thread 0x"); + puts(fd, "Thread "); } - let thread_id = current_thread_id(); - // Use appropriate width based on platform pointer size dump_hexadecimal(fd, thread_id, core::mem::size_of::() * 2); puts(fd, " (most recent call first):\n"); } - // dump_frame (traceback.c:1037-1087) + /// Dump the current thread's live frame chain to fd (signal-safe). + /// Walks the `Frame.previous` pointer chain starting from the + /// thread-local current frame pointer. #[cfg(any(unix, windows))] - fn dump_frame(fd: i32, filename: &[u8], lineno: u32, funcname: &[u8]) { - puts(fd, " File \""); - let _ = unsafe { - #[cfg(windows)] - { - libc::write( - fd, - filename.as_ptr() as *const libc::c_void, - filename.len() as u32, - ) - } - #[cfg(not(windows))] - { - libc::write(fd, filename.as_ptr() as *const libc::c_void, filename.len()) + fn dump_live_frames(fd: i32) { + const MAX_FRAME_DEPTH: usize = 100; + + let mut frame_ptr = crate::vm::vm::thread::get_current_frame(); + if frame_ptr.is_null() { + puts(fd, " \n"); + return; + } + let mut depth = 0; + while !frame_ptr.is_null() && depth < MAX_FRAME_DEPTH { + let frame = unsafe { &*frame_ptr }; + dump_frame_from_raw(fd, frame); + frame_ptr = frame.previous_frame(); + depth += 1; + } + if depth >= MAX_FRAME_DEPTH && !frame_ptr.is_null() { + puts(fd, " ...\n"); + } + } + + /// Dump a single frame's info to fd (signal-safe), reading live data. + #[cfg(any(unix, windows))] + fn dump_frame_from_raw(fd: i32, frame: &Frame) { + let filename = frame.code.source_path.as_str(); + let funcname = frame.code.obj_name.as_str(); + let lasti = frame.lasti(); + let lineno = if lasti == 0 { + frame.code.first_line_number.map(|n| n.get()).unwrap_or(1) as u32 + } else { + let idx = (lasti as usize).saturating_sub(1); + if idx < frame.code.locations.len() { + frame.code.locations[idx].0.line.get() as u32 + } else { + frame.code.first_line_number.map(|n| n.get()).unwrap_or(0) as u32 } }; + + puts(fd, " File \""); + dump_ascii(fd, filename); puts(fd, "\", line "); dump_decimal(fd, lineno as usize); puts(fd, " in "); - let _ = unsafe { - #[cfg(windows)] - { - libc::write( - fd, - funcname.as_ptr() as *const libc::c_void, - funcname.len() as u32, - ) - } - #[cfg(not(windows))] - { - libc::write(fd, funcname.as_ptr() as *const libc::c_void, funcname.len()) - } - }; + dump_ascii(fd, funcname); puts(fd, "\n"); } - // faulthandler_dump_traceback + // faulthandler_dump_traceback (signal-safe, for fatal errors) #[cfg(any(unix, windows))] - fn faulthandler_dump_traceback(fd: i32, _all_threads: bool) { + fn faulthandler_dump_traceback(fd: i32, all_threads: bool) { static REENTRANT: AtomicBool = AtomicBool::new(false); if REENTRANT.swap(true, Ordering::SeqCst) { @@ -295,76 +285,82 @@ mod decl { } // Write thread header - write_thread_id(fd, true); - - // Try to dump traceback from snapshot - let count = SNAPSHOT_COUNT.load(Ordering::Acquire); - if count > 0 { - // Using index access instead of iterator because FRAME_SNAPSHOTS is static mut - #[allow(clippy::needless_range_loop)] - for i in 0..count { - unsafe { - let snap = &FRAME_SNAPSHOTS[i]; - if snap.filename_len > 0 { - dump_frame( - fd, - &snap.filename[..snap.filename_len], - snap.lineno, - &snap.funcname[..snap.funcname_len], - ); - } - } - } + if all_threads { + write_thread_id(fd, current_thread_id(), true); } else { - puts(fd, " \n"); + puts(fd, "Stack (most recent call first):\n"); } + dump_live_frames(fd); + REENTRANT.store(false, Ordering::SeqCst); } - const MAX_FUNCTION_NAME_LEN: usize = 500; + /// MAX_STRING_LENGTH in traceback.c + const MAX_STRING_LENGTH: usize = 500; - fn truncate_name(name: &str) -> String { - if name.len() > MAX_FUNCTION_NAME_LEN { - format!("{}...", &name[..MAX_FUNCTION_NAME_LEN]) - } else { - name.to_string() + /// Truncate a UTF-8 string to at most `max_bytes` without splitting a + /// multi-byte codepoint. Signal-safe (no allocation, no panic). + #[cfg(any(unix, windows))] + fn safe_truncate(s: &str, max_bytes: usize) -> (&str, bool) { + if s.len() <= max_bytes { + return (s, false); + } + let mut end = max_bytes; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; } + (&s[..end], true) } - fn get_file_for_output( - file: OptionalArg, - vm: &VirtualMachine, - ) -> PyResult { - match file { - OptionalArg::Present(f) => { - // If it's an integer, we can't use it directly as a file object - // For now, just return it and let the caller handle it - Ok(f) - } - OptionalArg::Missing => { - // Get sys.stderr - let stderr = vm.sys_module.get_attr("stderr", vm)?; - if vm.is_none(&stderr) { - return Err(vm.new_runtime_error("sys.stderr is None".to_owned())); - } - Ok(stderr) - } + /// Write a string to fd, truncating with "..." if it exceeds MAX_STRING_LENGTH. + /// Mirrors `_Py_DumpASCII` truncation behavior. + #[cfg(any(unix, windows))] + fn dump_ascii(fd: i32, s: &str) { + let (truncated_s, was_truncated) = safe_truncate(s, MAX_STRING_LENGTH); + puts(fd, truncated_s); + if was_truncated { + puts(fd, "..."); } } - fn collect_frame_info(frame: &crate::vm::PyRef) -> String { - let func_name = truncate_name(frame.code.obj_name.as_str()); - // If lasti is 0, execution hasn't started yet - use first line number or 1 - let line = if frame.lasti() == 0 { - frame.code.first_line_number.map(|n| n.get()).unwrap_or(1) + /// Write a frame's info to an fd using signal-safe I/O. + #[cfg(any(unix, windows))] + fn dump_frame_from_ref(fd: i32, frame: &crate::vm::PyRef) { + let funcname = frame.code.obj_name.as_str(); + let filename = frame.code.source_path.as_str(); + let lineno = if frame.lasti() == 0 { + frame.code.first_line_number.map(|n| n.get()).unwrap_or(1) as u32 } else { - frame.current_location().line.get() + frame.current_location().line.get() as u32 }; - format!( - " File \"{}\", line {} in {}", - frame.code.source_path, line, func_name - ) + + puts(fd, " File \""); + dump_ascii(fd, filename); + puts(fd, "\", line "); + dump_decimal(fd, lineno as usize); + puts(fd, " in "); + dump_ascii(fd, funcname); + puts(fd, "\n"); + } + + /// Dump traceback for a thread given its frame stack (for cross-thread dumping). + #[cfg(all(any(unix, windows), feature = "threading"))] + fn dump_traceback_thread_frames( + fd: i32, + thread_id: u64, + is_current: bool, + frames: &[crate::vm::frame::FrameRef], + ) { + write_thread_id(fd, thread_id, is_current); + + if frames.is_empty() { + puts(fd, " \n"); + } else { + for frame in frames.iter().rev() { + dump_frame_from_ref(fd, frame); + } + } } #[derive(FromArgs)] @@ -377,22 +373,70 @@ mod decl { #[pyfunction] fn dump_traceback(args: DumpTracebackArgs, vm: &VirtualMachine) -> PyResult<()> { - let _ = args.all_threads; // TODO: implement all_threads support - - let file = get_file_for_output(args.file, vm)?; + let fd = get_fd_from_file_opt(args.file, vm)?; - // Collect frame info first to avoid RefCell borrow conflict - let frame_lines: Vec = vm.frames.borrow().iter().map(collect_frame_info).collect(); + #[cfg(any(unix, windows))] + { + if args.all_threads { + dump_all_threads(fd, vm); + } else { + puts(fd, "Stack (most recent call first):\n"); + let frames = vm.frames.borrow(); + for frame in frames.iter().rev() { + dump_frame_from_ref(fd, frame); + } + } + } - // Now write to file (in reverse order - most recent call first) - let mut writer = crate::vm::py_io::PyWriter(file, vm); - writeln!(writer, "Stack (most recent call first):")?; - for line in frame_lines.iter().rev() { - writeln!(writer, "{}", line)?; + #[cfg(not(any(unix, windows)))] + { + let _ = (fd, args.all_threads); } + Ok(()) } + /// Dump tracebacks of all threads. + #[cfg(any(unix, windows))] + fn dump_all_threads(fd: i32, vm: &VirtualMachine) { + // Get all threads' frame stacks from the shared registry + #[cfg(feature = "threading")] + { + let current_tid = rustpython_vm::stdlib::thread::get_ident(); + let registry = vm.state.thread_frames.lock(); + + // First dump non-current threads, then current thread last + for (&tid, slot) in registry.iter() { + if tid == current_tid { + continue; + } + let frames_guard = slot.lock(); + dump_traceback_thread_frames(fd, tid, false, &frames_guard); + puts(fd, "\n"); + } + + // Now dump current thread (use vm.frames for most up-to-date data) + write_thread_id(fd, current_tid, true); + let frames = vm.frames.borrow(); + if frames.is_empty() { + puts(fd, " \n"); + } else { + for frame in frames.iter().rev() { + dump_frame_from_ref(fd, frame); + } + } + } + + #[cfg(not(feature = "threading"))] + { + write_thread_id(fd, current_thread_id(), true); + let frames = vm.frames.borrow(); + for frame in frames.iter().rev() { + dump_frame_from_ref(fd, frame); + } + } + } + #[derive(FromArgs)] #[allow(unused)] struct EnableArgs { @@ -464,9 +508,8 @@ mod decl { .find(|h| h.signum == signum) }; - // faulthandler_fatal_error if let Some(h) = handler { - // Disable handler first (restores previous) + // Disable handler (restores previous) unsafe { faulthandler_disable_fatal_handler(h); } @@ -480,18 +523,24 @@ mod decl { puts(fd, "\n\n"); } - // faulthandler_dump_traceback let all_threads = FATAL_ERROR.all_threads.load(Ordering::Relaxed); faulthandler_dump_traceback(fd, all_threads); - // restore errno set_errno(save_errno); - // raise - // Called immediately thanks to SA_NODEFER flag + // Reset to default handler and re-raise to ensure process terminates. + // We cannot just restore the previous handler because Rust's runtime + // may have installed its own SIGSEGV handler (for stack overflow detection) + // that doesn't terminate the process on software-raised signals. unsafe { + libc::signal(signum, libc::SIG_DFL); libc::raise(signum); } + + // Fallback if raise() somehow didn't terminate the process + unsafe { + libc::_exit(1); + } } // faulthandler_fatal_error for Windows @@ -529,14 +578,84 @@ mod decl { set_errno(save_errno); - // On Windows, don't explicitly call the previous handler for SIGSEGV - if signum == libc::SIGSEGV { - return; - } - unsafe { + libc::signal(signum, libc::SIG_DFL); libc::raise(signum); } + + // Fallback + std::process::exit(1); + } + + // Windows vectored exception handler (faulthandler.c:417-480) + #[cfg(windows)] + static EXC_HANDLER: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0); + + #[cfg(windows)] + fn faulthandler_ignore_exception(code: u32) -> bool { + // bpo-30557: ignore exceptions which are not errors + if (code & 0x80000000) == 0 { + return true; + } + // bpo-31701: ignore MSC and COM exceptions + if code == 0xE06D7363 || code == 0xE0434352 { + return true; + } + false + } + + #[cfg(windows)] + unsafe extern "system" fn faulthandler_exc_handler( + exc_info: *mut windows_sys::Win32::System::Diagnostics::Debug::EXCEPTION_POINTERS, + ) -> i32 { + const EXCEPTION_CONTINUE_SEARCH: i32 = 0; + + if !FATAL_ERROR.enabled.load(Ordering::Relaxed) { + return EXCEPTION_CONTINUE_SEARCH; + } + + let record = unsafe { &*(*exc_info).ExceptionRecord }; + let code = record.ExceptionCode as u32; + + if faulthandler_ignore_exception(code) { + return EXCEPTION_CONTINUE_SEARCH; + } + + let fd = FATAL_ERROR.fd.load(Ordering::Relaxed); + + puts(fd, "Windows fatal exception: "); + match code { + 0xC0000005 => puts(fd, "access violation"), + 0xC000008C => puts(fd, "float divide by zero"), + 0xC0000091 => puts(fd, "float overflow"), + 0xC0000094 => puts(fd, "int divide by zero"), + 0xC0000095 => puts(fd, "integer overflow"), + 0xC0000006 => puts(fd, "page error"), + 0xC00000FD => puts(fd, "stack overflow"), + 0xC000001D => puts(fd, "illegal instruction"), + _ => { + puts(fd, "code "); + dump_hexadecimal(fd, code as u64, 8); + } + } + puts(fd, "\n\n"); + + // Disable SIGSEGV handler for access violations to avoid double output + if code == 0xC0000005 { + unsafe { + for handler in FAULTHANDLER_HANDLERS.iter_mut() { + if handler.signum == libc::SIGSEGV { + faulthandler_disable_fatal_handler(handler); + break; + } + } + } + } + + let all_threads = FATAL_ERROR.all_threads.load(Ordering::Relaxed); + faulthandler_dump_traceback(fd, all_threads); + + EXCEPTION_CONTINUE_SEARCH } // faulthandler_enable @@ -595,6 +714,14 @@ mod decl { } } + // Register Windows vectored exception handler + #[cfg(windows)] + { + use windows_sys::Win32::System::Diagnostics::Debug::AddVectoredExceptionHandler; + let h = unsafe { AddVectoredExceptionHandler(1, Some(faulthandler_exc_handler)) }; + EXC_HANDLER.store(h as usize, Ordering::Relaxed); + } + FATAL_ERROR.enabled.store(true, Ordering::Relaxed); true } @@ -611,6 +738,18 @@ mod decl { faulthandler_disable_fatal_handler(handler); } } + + // Remove Windows vectored exception handler + #[cfg(windows)] + { + use windows_sys::Win32::System::Diagnostics::Debug::RemoveVectoredExceptionHandler; + let h = EXC_HANDLER.swap(0, Ordering::Relaxed); + if h != 0 { + unsafe { + RemoveVectoredExceptionHandler(h as *mut core::ffi::c_void); + } + } + } } #[cfg(not(any(unix, windows)))] @@ -646,16 +785,17 @@ mod decl { let hour = min / 60; let min = min % 60; + // Match Python's timedelta str format: H:MM:SS.ffffff (no leading zero for hours) if us != 0 { - format!("Timeout ({:02}:{:02}:{:02}.{:06})!\n", hour, min, sec, us) + format!("Timeout ({}:{:02}:{:02}.{:06})!\n", hour, min, sec, us) } else { - format!("Timeout ({:02}:{:02}:{:02})!\n", hour, min, sec) + format!("Timeout ({}:{:02}:{:02})!\n", hour, min, sec) } } fn get_fd_from_file_opt(file: OptionalArg, vm: &VirtualMachine) -> PyResult { match file { - OptionalArg::Present(f) => { + OptionalArg::Present(f) if !vm.is_none(&f) => { // Check if it's an integer (file descriptor) if let Ok(fd) = f.try_to_value::(vm) { if fd < 0 { @@ -677,8 +817,8 @@ mod decl { let _ = vm.call_method(&f, "flush", ()); Ok(fd) } - OptionalArg::Missing => { - // Get sys.stderr + _ => { + // file=None or file not passed: fall back to sys.stderr let stderr = vm.sys_module.get_attr("stderr", vm)?; if vm.is_none(&stderr) { return Err(vm.new_runtime_error("sys.stderr is None".to_owned())); @@ -709,8 +849,12 @@ mod decl { } // Extract values before releasing lock for I/O - let (repeat, exit, fd, header) = - (guard.repeat, guard.exit, guard.fd, guard.header.clone()); + let repeat = guard.repeat; + let exit = guard.exit; + let fd = guard.fd; + let header = guard.header.clone(); + #[cfg(feature = "threading")] + let thread_frame_slots = guard.thread_frame_slots.clone(); drop(guard); // Release lock before I/O // Timeout occurred, dump traceback @@ -719,35 +863,21 @@ mod decl { #[cfg(not(target_arch = "wasm32"))] { - let header_bytes = header.as_bytes(); - #[cfg(windows)] - unsafe { - libc::write( - fd, - header_bytes.as_ptr() as *const libc::c_void, - header_bytes.len() as u32, - ); - } - #[cfg(not(windows))] - unsafe { - libc::write( - fd, - header_bytes.as_ptr() as *const libc::c_void, - header_bytes.len(), - ); - } - - // Note: We cannot dump actual Python traceback from a separate thread - // because we don't have access to the VM's frame stack. - // Just output a message indicating timeout occurred. - let msg = b"\n"; - #[cfg(windows)] - unsafe { - libc::write(fd, msg.as_ptr() as *const libc::c_void, msg.len() as u32); + puts_bytes(fd, header.as_bytes()); + + // Use thread frame slots when threading is enabled (includes all threads). + // Fall back to live frame walking for non-threaded builds. + #[cfg(feature = "threading")] + { + for (tid, slot) in &thread_frame_slots { + let frames = slot.lock(); + dump_traceback_thread_frames(fd, *tid, false, &frames); + } } - #[cfg(not(windows))] - unsafe { - libc::write(fd, msg.as_ptr() as *const libc::c_void, msg.len()); + #[cfg(not(feature = "threading"))] + { + write_thread_id(fd, current_thread_id(), false); + dump_live_frames(fd); } if exit { @@ -792,6 +922,16 @@ mod decl { let header = format_timeout(timeout_us); + // Snapshot thread frame slots so watchdog can dump tracebacks + #[cfg(feature = "threading")] + let thread_frame_slots: Vec<(u64, ThreadFrameSlot)> = { + let registry = vm.state.thread_frames.lock(); + registry + .iter() + .map(|(&id, slot)| (id, Arc::clone(slot))) + .collect() + }; + // Cancel any previous watchdog cancel_dump_traceback_later(); @@ -804,6 +944,8 @@ mod decl { repeat: args.repeat, exit: args.exit, header, + #[cfg(feature = "threading")] + thread_frame_slots, }), Condvar::new(), )); @@ -845,14 +987,13 @@ mod decl { const NSIG: usize = 64; - #[derive(Clone)] + #[derive(Clone, Copy)] pub struct UserSignal { pub enabled: bool, pub fd: i32, - #[allow(dead_code)] pub all_threads: bool, pub chain: bool, - pub previous: libc::sighandler_t, + pub previous: libc::sigaction, } impl Default for UserSignal { @@ -862,7 +1003,8 @@ mod decl { fd: 2, // stderr all_threads: true, chain: false, - previous: libc::SIG_DFL, + // SAFETY: sigaction is a C struct that can be zero-initialized + previous: unsafe { core::mem::zeroed() }, } } } @@ -892,7 +1034,7 @@ mod decl { && signum < v.len() && v[signum].enabled { - let old = v[signum].clone(); + let old = v[signum]; v[signum] = UserSignal::default(); return Some(old); } @@ -910,38 +1052,33 @@ mod decl { #[cfg(unix)] extern "C" fn faulthandler_user_signal(signum: libc::c_int) { + let save_errno = get_errno(); + let user = match user_signals::get_user_signal(signum as usize) { Some(u) if u.enabled => u, _ => return, }; - // Write traceback header - let header = b"Current thread 0x0000 (most recent call first):\n"; - let _ = unsafe { - libc::write( - user.fd, - header.as_ptr() as *const libc::c_void, - header.len(), - ) - }; + faulthandler_dump_traceback(user.fd, user.all_threads); - // Note: We cannot easily access RustPython's frame stack from a signal handler - // because signal handlers run asynchronously. We just output a placeholder. - let msg = b" \n"; - let _ = unsafe { libc::write(user.fd, msg.as_ptr() as *const libc::c_void, msg.len()) }; - - // If chain is enabled, call the previous handler - if user.chain && user.previous != libc::SIG_DFL && user.previous != libc::SIG_IGN { - // Re-register the old handler and raise the signal + if user.chain { + // Restore the previous handler and re-raise + unsafe { + libc::sigaction(signum, &user.previous, core::ptr::null_mut()); + } + set_errno(save_errno); unsafe { - libc::signal(signum, user.previous); libc::raise(signum); - // Re-register our handler - libc::signal( - signum, - faulthandler_user_signal as *const () as libc::sighandler_t, - ); } + // Re-install our handler with the same flags as register() + let save_errno2 = get_errno(); + unsafe { + let mut action: libc::sigaction = core::mem::zeroed(); + action.sa_sigaction = faulthandler_user_signal as *const () as libc::sighandler_t; + action.sa_flags = libc::SA_NODEFER; + libc::sigaction(signum, &action, core::ptr::null_mut()); + } + set_errno(save_errno2); } } @@ -989,25 +1126,31 @@ mod decl { // Get current handler to save as previous let previous = if !user_signals::is_enabled(signum) { - // Install signal handler - let prev = unsafe { - libc::signal( - args.signum, - faulthandler_user_signal as *const () as libc::sighandler_t, - ) - }; - if prev == libc::SIG_ERR { - return Err(vm.new_os_error(format!( - "Failed to register signal handler for signal {}", - args.signum - ))); + unsafe { + let mut action: libc::sigaction = core::mem::zeroed(); + action.sa_sigaction = faulthandler_user_signal as *const () as libc::sighandler_t; + // SA_RESTART by default; SA_NODEFER only when chaining + // (faulthandler.c:860-864) + action.sa_flags = if args.chain { + libc::SA_NODEFER + } else { + libc::SA_RESTART + }; + + let mut prev: libc::sigaction = core::mem::zeroed(); + if libc::sigaction(args.signum, &action, &mut prev) != 0 { + return Err(vm.new_os_error(format!( + "Failed to register signal handler for signal {}", + args.signum + ))); + } + prev } - prev } else { // Already registered, keep previous handler user_signals::get_user_signal(signum) .map(|u| u.previous) - .unwrap_or(libc::SIG_DFL) + .unwrap_or(unsafe { core::mem::zeroed() }) }; user_signals::set_user_signal( @@ -1032,7 +1175,7 @@ mod decl { if let Some(old) = user_signals::clear_user_signal(signum as usize) { // Restore previous handler unsafe { - libc::signal(signum, old.previous); + libc::sigaction(signum, &old.previous, core::ptr::null_mut()); } Ok(true) } else { @@ -1043,14 +1186,15 @@ mod decl { // Test functions for faulthandler testing #[pyfunction] - fn _read_null() { - // This function intentionally causes a segmentation fault by reading from NULL - // Used for testing faulthandler + fn _read_null(_vm: &VirtualMachine) { #[cfg(not(target_arch = "wasm32"))] - unsafe { + { suppress_crash_report(); - let ptr: *const i32 = core::ptr::null(); - core::ptr::read_volatile(ptr); + + unsafe { + let ptr: *const i32 = core::ptr::null(); + core::ptr::read_volatile(ptr); + } } } @@ -1062,39 +1206,28 @@ mod decl { } #[pyfunction] - fn _sigsegv(_args: SigsegvArgs) { - // Raise SIGSEGV signal + fn _sigsegv(_args: SigsegvArgs, _vm: &VirtualMachine) { #[cfg(not(target_arch = "wasm32"))] { suppress_crash_report(); - // Reset SIGSEGV to default behavior before raising - // This ensures the process will actually crash + // Write to NULL pointer to trigger a real hardware SIGSEGV, + // matching CPython's *((volatile int *)NULL) = 0; + // Using raise(SIGSEGV) doesn't work reliably because Rust's runtime + // installs its own signal handler that may swallow software signals. unsafe { - libc::signal(libc::SIGSEGV, libc::SIG_DFL); - } - - #[cfg(windows)] - { - // On Windows, we need to raise SIGSEGV multiple times - loop { - unsafe { - libc::raise(libc::SIGSEGV); - } - } - } - #[cfg(not(windows))] - unsafe { - libc::raise(libc::SIGSEGV); + let ptr: *mut i32 = core::ptr::null_mut(); + core::ptr::write_volatile(ptr, 0); } } } #[pyfunction] - fn _sigabrt() { + fn _sigabrt(_vm: &VirtualMachine) { #[cfg(not(target_arch = "wasm32"))] { suppress_crash_report(); + unsafe { libc::abort(); } @@ -1102,17 +1235,11 @@ mod decl { } #[pyfunction] - fn _sigfpe() { + fn _sigfpe(_vm: &VirtualMachine) { #[cfg(not(target_arch = "wasm32"))] { suppress_crash_report(); - // Reset SIGFPE to default behavior before raising - unsafe { - libc::signal(libc::SIGFPE, libc::SIG_DFL); - } - - // Raise SIGFPE unsafe { libc::raise(libc::SIGFPE); } @@ -1196,7 +1323,7 @@ mod decl { #[cfg(windows)] #[pyfunction] - fn _raise_exception(args: RaiseExceptionArgs) { + fn _raise_exception(args: RaiseExceptionArgs, _vm: &VirtualMachine) { use windows_sys::Win32::System::Diagnostics::Debug::RaiseException; suppress_crash_report(); diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index ced0c07f271..90c20a62597 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -25,8 +25,8 @@ use crate::{ }; use alloc::fmt; use core::iter::zip; -#[cfg(feature = "threading")] use core::sync::atomic; +use core::sync::atomic::AtomicPtr; use indexmap::IndexMap; use itertools::Itertools; @@ -90,6 +90,9 @@ pub struct Frame { /// Borrowed reference (not ref-counted) to avoid Generator↔Frame cycle. /// Cleared by the generator's Drop impl. pub generator: PyAtomicBorrow, + /// Previous frame in the call chain for signal-safe traceback walking. + /// Mirrors `_PyInterpreterFrame.previous`. + pub(crate) previous: AtomicPtr, } impl PyPayload for Frame { @@ -179,6 +182,7 @@ impl Frame { trace_opcodes: PyMutex::new(false), temporary_refs: PyMutex::new(vec![]), generator: PyAtomicBorrow::new(), + previous: AtomicPtr::new(core::ptr::null_mut()), } } @@ -197,6 +201,11 @@ impl Frame { self.code.locations[self.lasti() as usize - 1].0 } + /// Get the previous frame pointer for signal-safe traceback walking. + pub fn previous_frame(&self) -> *const Frame { + self.previous.load(atomic::Ordering::Relaxed) + } + pub fn lasti(&self) -> u32 { #[cfg(feature = "threading")] { diff --git a/crates/vm/src/signal.rs b/crates/vm/src/signal.rs index 4aa245ad190..d0e2997cb72 100644 --- a/crates/vm/src/signal.rs +++ b/crates/vm/src/signal.rs @@ -2,6 +2,7 @@ use crate::{PyResult, VirtualMachine}; use alloc::fmt; use core::sync::atomic::{AtomicBool, Ordering}; +use std::cell::Cell; use std::sync::mpsc; pub(crate) const NSIG: usize = 64; @@ -11,6 +12,20 @@ static ANY_TRIGGERED: AtomicBool = AtomicBool::new(false); const ATOMIC_FALSE: AtomicBool = AtomicBool::new(false); pub(crate) static TRIGGERS: [AtomicBool; NSIG] = [ATOMIC_FALSE; NSIG]; +thread_local! { + /// Prevent recursive signal handler invocation. When a Python signal + /// handler is running, new signals are deferred until it completes. + static IN_SIGNAL_HANDLER: Cell = const { Cell::new(false) }; +} + +struct SignalHandlerGuard; + +impl Drop for SignalHandlerGuard { + fn drop(&mut self) { + IN_SIGNAL_HANDLER.with(|h| h.set(false)); + } +} + #[cfg_attr(feature = "flame-it", flame)] #[inline(always)] pub fn check_signals(vm: &VirtualMachine) -> PyResult<()> { @@ -27,6 +42,13 @@ pub fn check_signals(vm: &VirtualMachine) -> PyResult<()> { #[inline(never)] #[cold] fn trigger_signals(vm: &VirtualMachine) -> PyResult<()> { + if IN_SIGNAL_HANDLER.with(|h| h.replace(true)) { + // Already inside a signal handler — defer pending signals + set_triggered(); + return Ok(()); + } + let _guard = SignalHandlerGuard; + // unwrap should never fail since we check above let signal_handlers = vm.signal_handlers.as_ref().unwrap().borrow(); for (signum, trigger) in TRIGGERS.iter().enumerate().skip(1) { diff --git a/crates/vm/src/stdlib/thread.rs b/crates/vm/src/stdlib/thread.rs index 22457b3f17f..fe99dcbdf02 100644 --- a/crates/vm/src/stdlib/thread.rs +++ b/crates/vm/src/stdlib/thread.rs @@ -1,9 +1,10 @@ //! Implementation of the _thread module #[cfg(unix)] pub(crate) use _thread::after_fork_child; +pub use _thread::get_ident; #[cfg_attr(target_arch = "wasm32", allow(unused_imports))] pub(crate) use _thread::{ - CurrentFrameSlot, HandleEntry, RawRMutex, ShutdownEntry, get_all_current_frames, get_ident, + CurrentFrameSlot, HandleEntry, RawRMutex, ShutdownEntry, get_all_current_frames, init_main_thread_ident, module_def, }; @@ -873,12 +874,12 @@ pub(crate) mod _thread { // Re-export type from vm::thread for PyGlobalState pub use crate::vm::thread::CurrentFrameSlot; - /// Get all threads' current frames. Used by sys._current_frames(). + /// Get all threads' current (top) frames. Used by sys._current_frames(). pub fn get_all_current_frames(vm: &VirtualMachine) -> Vec<(u64, FrameRef)> { let registry = vm.state.thread_frames.lock(); registry .iter() - .filter_map(|(id, slot)| slot.lock().clone().map(|f| (*id, f))) + .filter_map(|(id, slot)| slot.lock().last().cloned().map(|f| (*id, f))) .collect() } diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 48b5655a9eb..5adf3cfa2a3 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -939,9 +939,16 @@ impl VirtualMachine { ) -> PyResult { self.with_recursion("", || { self.frames.borrow_mut().push(frame.clone()); - // Update the current frame slot for sys._current_frames() + // Update the shared frame stack for sys._current_frames() and faulthandler #[cfg(feature = "threading")] - crate::vm::thread::update_current_frame(Some(frame.clone())); + crate::vm::thread::push_thread_frame(frame.clone()); + // Link frame into the signal-safe frame chain (previous pointer) + let frame_ptr: *const Frame = &**frame; + let old_frame = crate::vm::thread::set_current_frame(frame_ptr); + frame.previous.store( + old_frame as *mut Frame, + core::sync::atomic::Ordering::Relaxed, + ); // Push a new exception context for frame isolation // Each frame starts with no active exception (None) // This prevents exceptions from leaking between function calls @@ -949,11 +956,13 @@ impl VirtualMachine { let result = f(frame); // Pop the exception context - restores caller's exception state self.pop_exception(); + // Restore previous frame as current (unlink from chain) + crate::vm::thread::set_current_frame(old_frame); // defer dec frame let _popped = self.frames.borrow_mut().pop(); - // Update the frame slot to the new top frame (or None if empty) + // Pop from shared frame stack #[cfg(feature = "threading")] - crate::vm::thread::update_current_frame(self.frames.borrow().last().cloned()); + crate::vm::thread::pop_thread_frame(); result }) } diff --git a/crates/vm/src/vm/thread.rs b/crates/vm/src/vm/thread.rs index fb8621d1526..c3d69bc3e61 100644 --- a/crates/vm/src/vm/thread.rs +++ b/crates/vm/src/vm/thread.rs @@ -1,9 +1,11 @@ +use crate::frame::Frame; #[cfg(feature = "threading")] use crate::frame::FrameRef; use crate::{AsObject, PyObject, VirtualMachine}; use core::{ cell::{Cell, RefCell}, ptr::NonNull, + sync::atomic::{AtomicPtr, Ordering}, }; use itertools::Itertools; #[cfg(feature = "threading")] @@ -11,8 +13,10 @@ use std::sync::Arc; use std::thread_local; /// Type for current frame slot - shared between threads for sys._current_frames() +/// Stores the full frame stack so faulthandler can dump complete tracebacks +/// for all threads. #[cfg(feature = "threading")] -pub type CurrentFrameSlot = Arc>>; +pub type CurrentFrameSlot = Arc>>; thread_local! { pub(super) static VM_STACK: RefCell>> = Vec::with_capacity(1).into(); @@ -22,6 +26,14 @@ thread_local! { /// Current thread's frame slot for sys._current_frames() #[cfg(feature = "threading")] static CURRENT_FRAME_SLOT: RefCell> = const { RefCell::new(None) }; + + /// Current top frame for signal-safe traceback walking. + /// Mirrors `PyThreadState.current_frame`. Read by faulthandler's signal + /// handler to dump tracebacks without accessing RefCell or locks. + /// Uses AtomicPtr for async-signal-safety (signal handlers may read this + /// while the owning thread is writing). + pub(crate) static CURRENT_FRAME: AtomicPtr = + const { AtomicPtr::new(core::ptr::null_mut()) }; } scoped_tls::scoped_thread_local!(static VM_CURRENT: VirtualMachine); @@ -53,7 +65,7 @@ fn init_frame_slot_if_needed(vm: &VirtualMachine) { CURRENT_FRAME_SLOT.with(|slot| { if slot.borrow().is_none() { let thread_id = crate::stdlib::thread::get_ident(); - let new_slot = Arc::new(parking_lot::Mutex::new(None)); + let new_slot = Arc::new(parking_lot::Mutex::new(Vec::new())); vm.state .thread_frames .lock() @@ -63,17 +75,40 @@ fn init_frame_slot_if_needed(vm: &VirtualMachine) { }); } -/// Update the current thread's frame. Called when frames are pushed/popped. -/// This is a hot path - uses only thread-local storage, no locks. +/// Push a frame onto the current thread's shared frame stack. +/// Called when a new frame is entered. +#[cfg(feature = "threading")] +pub fn push_thread_frame(frame: FrameRef) { + CURRENT_FRAME_SLOT.with(|slot| { + if let Some(s) = slot.borrow().as_ref() { + s.lock().push(frame); + } + }); +} + +/// Pop a frame from the current thread's shared frame stack. +/// Called when a frame is exited. #[cfg(feature = "threading")] -pub fn update_current_frame(frame: Option) { +pub fn pop_thread_frame() { CURRENT_FRAME_SLOT.with(|slot| { if let Some(s) = slot.borrow().as_ref() { - *s.lock() = frame; + s.lock().pop(); } }); } +/// Set the current thread's top frame pointer for signal-safe traceback walking. +/// Returns the previous frame pointer so it can be restored on pop. +pub fn set_current_frame(frame: *const Frame) -> *const Frame { + CURRENT_FRAME.with(|c| c.swap(frame as *mut Frame, Ordering::Relaxed) as *const Frame) +} + +/// Get the current thread's top frame pointer. +/// Used by faulthandler's signal handler to start traceback walking. +pub fn get_current_frame() -> *const Frame { + CURRENT_FRAME.with(|c| c.load(Ordering::Relaxed) as *const Frame) +} + /// Cleanup frame tracking for the current thread. Called at thread exit. #[cfg(feature = "threading")] pub fn cleanup_current_thread_frames(vm: &VirtualMachine) { @@ -85,20 +120,31 @@ pub fn cleanup_current_thread_frames(vm: &VirtualMachine) { } /// Reinitialize frame slot after fork. Called in child process. -/// Creates a fresh slot and registers it for the current thread. +/// Creates a fresh slot and registers it for the current thread, +/// preserving the current thread's frames from `vm.frames`. #[cfg(feature = "threading")] pub fn reinit_frame_slot_after_fork(vm: &VirtualMachine) { let current_ident = crate::stdlib::thread::get_ident(); - let new_slot = Arc::new(parking_lot::Mutex::new(None)); + // Preserve the current thread's frames across fork + let current_frames: Vec = vm.frames.borrow().clone(); + let new_slot = Arc::new(parking_lot::Mutex::new(current_frames)); - // Try to update the global registry. If we can't get the lock - // (parent thread might have been holding it during fork), skip. - if let Some(mut registry) = vm.state.thread_frames.try_lock() { - registry.clear(); - registry.insert(current_ident, new_slot.clone()); - } + // After fork, only the current thread exists. If the lock was held by + // another thread during fork, force unlock it. + let mut registry = match vm.state.thread_frames.try_lock() { + Some(guard) => guard, + None => { + // SAFETY: After fork in child process, only the current thread + // exists. The lock holder no longer exists. + unsafe { vm.state.thread_frames.force_unlock() }; + vm.state.thread_frames.lock() + } + }; + registry.clear(); + registry.insert(current_ident, new_slot.clone()); + drop(registry); - // Always update thread-local to point to the new slot + // Update thread-local to point to the new slot CURRENT_FRAME_SLOT.with(|s| { *s.borrow_mut() = Some(new_slot); });