Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions fusil/python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
25 changes: 21 additions & 4 deletions fusil/python/write_python_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions tests/python/golden/fakemod_seed1234.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/python/test_golden_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion tests/python/test_oom_fuzz.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions tests/python/test_target_introspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions tests/python/test_write_python_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading