diff --git a/fusil/python/__init__.py b/fusil/python/__init__.py index 900aae3..a943ece 100644 --- a/fusil/python/__init__.py +++ b/fusil/python/__init__.py @@ -292,6 +292,17 @@ def createFuzzerOptions(self, parser: OptionParser) -> None: action="store_true", default=False, ) + fuzzing_options.add_option( + "--no-faulthandler", + help="Don't enable faulthandler in the generated script. By default it is " + "enabled, so a target that dies on a fatal signal (SIGSEGV/SIGABRT/...) dumps a " + "Python-level backtrace into the session's stdout instead of just stopping. Use " + "this only if faulthandler's own signal handlers or internals interfere with the " + "run (e.g. when fuzzing signal handling itself, or if it perturbs --tsan race " + "signatures).", + action="store_true", + default=False, + ) fuzzing_options.add_option( "--filenames", help="Comma-separated readable files to feed as fuzz arguments. WARNING: a " diff --git a/fusil/python/write_python_code.py b/fusil/python/write_python_code.py index c2a2cca..f4c2230 100644 --- a/fusil/python/write_python_code.py +++ b/fusil/python/write_python_code.py @@ -383,12 +383,33 @@ def _get_object_methods( def _write_script_header_and_imports(self) -> None: """Writes standard imports and initial setup code to the generated script.""" + # Indented to match the dedent() block below: the placeholder sits at 16 spaces, so + # the first line inherits that and the continuations must supply their own. + faulthandler_setup = ( + "" + if self.options.no_faulthandler + else ( + "try:\n" + " import faulthandler as _fusil_faulthandler\n" + " _fusil_faulthandler.enable()\n" + " except Exception:\n" + " pass" + ) + ) self.write( 0, dedent( f"""\ # FUSIL_BOILERPLATE_START + # Record a Python-level backtrace if the target dies on a fatal signal. + # Without this a SIGSEGV/SIGABRT leaves stdout ending at the last call line + # with no indication of WHERE it died. That is worst for exactly the crashes + # that need it most -- rare, threaded, load-dependent ones that do not + # reproduce afterwards -- so capturing at crash time is the only chance. + # Enabled first, before any other import, so a crash in the prelude is caught + # too. Guarded: not every target interpreter ships a working faulthandler. + {faulthandler_setup} from gc import collect # NOTE: do NOT import `random` (the function) here -- it would shadow the # `random` module that embedded tricky-object code imports and uses as @@ -475,8 +496,6 @@ def skip_trivial_type(obj_instance_or_class): self.write_block( 0, """ - import faulthandler - faulthandler.enable() import ctypes try: _set_nomemory = ctypes.CDLL(None).fusil_malloc_arm @@ -495,8 +514,6 @@ def skip_trivial_type(obj_instance_or_class): self.write_block( 0, """ - import faulthandler - faulthandler.enable() try: from _testcapi import set_nomemory as _set_nomemory _OOM_AVAILABLE = True diff --git a/tests/python/golden/fakemod_seed1234.py b/tests/python/golden/fakemod_seed1234.py index ca8e443..915d61c 100644 --- a/tests/python/golden/fakemod_seed1234.py +++ b/tests/python/golden/fakemod_seed1234.py @@ -1,5 +1,17 @@ # FUSIL_BOILERPLATE_START +# Record a Python-level backtrace if the target dies on a fatal signal. +# Without this a SIGSEGV/SIGABRT leaves stdout ending at the last call line +# with no indication of WHERE it died. That is worst for exactly the crashes +# that need it most -- rare, threaded, load-dependent ones that do not +# reproduce afterwards -- so capturing at crash time is the only chance. +# Enabled first, before any other import, so a crash in the prelude is caught +# too. Guarded: not every target interpreter ships a working faulthandler. +try: + import faulthandler as _fusil_faulthandler + _fusil_faulthandler.enable() +except Exception: + pass from gc import collect # NOTE: do NOT import `random` (the function) here -- it would shadow the # `random` module that embedded tricky-object code imports and uses as diff --git a/tests/python/test_golden_output.py b/tests/python/test_golden_output.py index b7267eb..9d61d15 100644 --- a/tests/python/test_golden_output.py +++ b/tests/python/test_golden_output.py @@ -51,6 +51,7 @@ class _Options: test_private = False no_numpy = True no_tstrings = True + no_faulthandler = False # the real default: the golden covers the emitted block external_references = True oom_fuzz = False oom_max_start = 50 diff --git a/tests/python/test_oom_fuzz.py b/tests/python/test_oom_fuzz.py index e7f9c60..29f3a8b 100644 --- a/tests/python/test_oom_fuzz.py +++ b/tests/python/test_oom_fuzz.py @@ -53,6 +53,9 @@ def _make_options(oom_fuzz, oom_verbose=False): o.sys_monitoring = False # opt-in mode; a bare MagicMock attr would divert generation o.test_private = False o.no_numpy = True + # A bare MagicMock auto-vivifies a TRUTHY attribute, which would read as + # --no-faulthandler and suppress the prelude's crash-backtrace block. + o.no_faulthandler = False o.no_tstrings = True o.functions_number = 5 o.classes_number = 0 @@ -130,7 +133,7 @@ def test_oom_mode_emits_harness_and_sweeps(self): ast.parse(src) # Guarded _testcapi boilerplate + faulthandler. - self.assertIn("faulthandler.enable()", src) + self.assertIn("_fusil_faulthandler.enable()", src) # now from the prelude, all modes self.assertIn("from _testcapi import set_nomemory", src) self.assertIn("_OOM_AVAILABLE", src) diff --git a/tests/python/test_target_introspect.py b/tests/python/test_target_introspect.py index f449865..e0368eb 100644 --- a/tests/python/test_target_introspect.py +++ b/tests/python/test_target_introspect.py @@ -43,6 +43,7 @@ class _Options: fuzz_exceptions = False test_private = False no_numpy = True + no_faulthandler = False no_tstrings = True external_references = True no_threads = True diff --git a/tests/python/test_write_python_code.py b/tests/python/test_write_python_code.py index 2e93731..2bc7d41 100644 --- a/tests/python/test_write_python_code.py +++ b/tests/python/test_write_python_code.py @@ -562,3 +562,64 @@ def test_dispatch_fuzz_on_instance_stops_at_max_depth(self): if __name__ == "__main__": unittest.main() + + +class TestFaulthandlerPrelude(unittest.TestCase): + """A fatal signal must leave a backtrace in the session's stdout. + + Without faulthandler a SIGSEGV/SIGABRT just stops the output at the last call line with + no indication of where the target died. That is worst for the crashes that need it most: + rare, threaded, load-dependent ones that do not reproduce on a later replay, where the + crash-time dump is the only evidence there will ever be. Verified on PyPy 3.11: the dump + names the exact source.py line of the crashing call, from inside a worker thread. + """ + + def _header(self, **overrides): + source_agent = MagicMock() + source_agent.options = make_test_options( + no_numpy=True, no_tstrings=True, functions_number=1, **overrides + ) + module = ModuleType("fhmod") + # The constructor refuses a module with nothing to fuzz. + module.fh_func = lambda *a: a + writer = WritePythonCode( + parent_python_source=source_agent, + filename="out.py", + module=module, + module_name="fhmod", + threads=False, + _async=False, + ) + writer.output = StringIO() + writer._write_script_header_and_imports() + return writer.output.getvalue() + + def test_enabled_by_default(self): + header = self._header() + self.assertIn("import faulthandler as _fusil_faulthandler", header) + self.assertIn("_fusil_faulthandler.enable()", header) + + def test_enabled_before_any_other_import(self): + # A crash while the prelude itself is still running must be caught too. + header = self._header() + self.assertLess( + header.index("_fusil_faulthandler.enable()"), + header.index("from gc import collect"), + ) + + def test_guarded_so_a_target_without_faulthandler_still_runs(self): + header = self._header() + enable_at = header.index("_fusil_faulthandler.enable()") + self.assertIn("try:", header[:enable_at]) + self.assertIn("except Exception:", header[enable_at:]) + + def test_emitted_header_is_valid_python(self): + # The block is spliced into a dedent()ed f-string, so a wrong indent would break + # every generated script -- assert the prelude still parses, both ways. + for overrides in ({}, {"no_faulthandler": True}): + ast.parse(self._header(**overrides)) + + def test_no_faulthandler_option_omits_it(self): + header = self._header(no_faulthandler=True) + self.assertNotIn("_fusil_faulthandler", header) + self.assertIn("from gc import collect", header)