McRogueFace/tests/fuzz/fuzz_anim_timer_scene.py

533 lines
16 KiB
Python
Raw Normal View History

"""fuzz_anim_timer_scene - Wave 2 / W6 target for #283.
Add native libFuzzer fuzz harness for Python API, addresses #283 Pivots away from atheris (which lacks Python 3.14 support) to a single libFuzzer-linked executable that embeds CPython, registers mcrfpy, and dispatches each iteration to a Python fuzz_one_input(data: bytes) function loaded from tests/fuzz/fuzz_<target>.py by MCRF_FUZZ_TARGET env var. libFuzzer instruments the C++ engine code where all #258-#278 bugs live; Python drives the fuzzing logic via an in-house ByteStream replacement for atheris.FuzzedDataProvider. Python-level exceptions are caught; only ASan/UBSan signal real bugs. CMake - MCRF_FUZZER=ON builds mcrfpy_fuzz from all src/*.cpp except main.cpp plus tests/fuzz/fuzz_common.cpp, linked with -fsanitize=fuzzer,address, undefined. Asset+lib post-build copy added so the embedded interpreter finds its stdlib and default_font/default_texture load. Makefile - fuzz-build builds only mcrfpy_fuzz (fast iterate) - fuzz loops over six targets setting MCRF_FUZZ_TARGET for each - fuzz-long TARGET=x SECONDS=n for deep manual runs - fuzz-repro TARGET=x CRASH=path for crash reproduction - Shared ASAN_OPTIONS / PYTHONHOME env via FUZZ_ENV define tests/fuzz - fuzz_common.cpp: LLVMFuzzerInitialize bootstraps Python, imports target, resolves fuzz_one_input. LLVMFuzzerTestOneInput wraps bytes as PyBytes, calls target, swallows Python errors. - fuzz_common.py: ByteStream byte consumer + safe_reset() + EXPECTED_EXCEPTIONS - Six target stubs (grid_entity, property_types, anim_timer_scene, maps_procgen, fov, pathfinding_behavior) to be fleshed out in follow-up - README with build/run/triage instructions Verified end-to-end: make fuzz-build produces build-fuzz/mcrfpy_fuzz, make fuzz FUZZ_SECONDS=3 ran all six targets (~2400-9800 exec/s each, 667-1883 coverage edges), make fuzz-repro loaded and replayed a corpus input cleanly. No crashes from the stubs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:05:04 -04:00
Targets lifecycle-sensitive bugs:
- #269 PythonObjectCache::lookup() no mutex (race on cache)
- #270 GridLayer::parent_grid dangling pointer
- #275 UIEntity missing tp_dealloc (leak/dangle)
- #277 GridChunk::parent_grid dangling pointer
Add native libFuzzer fuzz harness for Python API, addresses #283 Pivots away from atheris (which lacks Python 3.14 support) to a single libFuzzer-linked executable that embeds CPython, registers mcrfpy, and dispatches each iteration to a Python fuzz_one_input(data: bytes) function loaded from tests/fuzz/fuzz_<target>.py by MCRF_FUZZ_TARGET env var. libFuzzer instruments the C++ engine code where all #258-#278 bugs live; Python drives the fuzzing logic via an in-house ByteStream replacement for atheris.FuzzedDataProvider. Python-level exceptions are caught; only ASan/UBSan signal real bugs. CMake - MCRF_FUZZER=ON builds mcrfpy_fuzz from all src/*.cpp except main.cpp plus tests/fuzz/fuzz_common.cpp, linked with -fsanitize=fuzzer,address, undefined. Asset+lib post-build copy added so the embedded interpreter finds its stdlib and default_font/default_texture load. Makefile - fuzz-build builds only mcrfpy_fuzz (fast iterate) - fuzz loops over six targets setting MCRF_FUZZ_TARGET for each - fuzz-long TARGET=x SECONDS=n for deep manual runs - fuzz-repro TARGET=x CRASH=path for crash reproduction - Shared ASAN_OPTIONS / PYTHONHOME env via FUZZ_ENV define tests/fuzz - fuzz_common.cpp: LLVMFuzzerInitialize bootstraps Python, imports target, resolves fuzz_one_input. LLVMFuzzerTestOneInput wraps bytes as PyBytes, calls target, swallows Python errors. - fuzz_common.py: ByteStream byte consumer + safe_reset() + EXPECTED_EXCEPTIONS - Six target stubs (grid_entity, property_types, anim_timer_scene, maps_procgen, fov, pathfinding_behavior) to be fleshed out in follow-up - README with build/run/triage instructions Verified end-to-end: make fuzz-build produces build-fuzz/mcrfpy_fuzz, make fuzz FUZZ_SECONDS=3 ran all six targets (~2400-9800 exec/s each, 667-1883 coverage edges), make fuzz-repro loaded and replayed a corpus input cleanly. No crashes from the stubs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:05:04 -04:00
Common thread: objects that hold references across scene/grid lifetimes
and misbehave when the lifetime ends unexpectedly. The target hammers
Animation, Timer, and Scene-graph mutation, focusing on callback paths
that fire AFTER a parent/target has been removed from the tree. Scene
swap mid-callback and closure captures that outlive their target are
the two main attack patterns.
Contract: fuzz_one_input(data: bytes) -> None. The C++ harness
(tests/fuzz/fuzz_common.cpp) calls safe_reset() before each iteration
and catches any Python exception that escapes. We still wrap the ops
loop to keep the libFuzzer output quiet.
Add native libFuzzer fuzz harness for Python API, addresses #283 Pivots away from atheris (which lacks Python 3.14 support) to a single libFuzzer-linked executable that embeds CPython, registers mcrfpy, and dispatches each iteration to a Python fuzz_one_input(data: bytes) function loaded from tests/fuzz/fuzz_<target>.py by MCRF_FUZZ_TARGET env var. libFuzzer instruments the C++ engine code where all #258-#278 bugs live; Python drives the fuzzing logic via an in-house ByteStream replacement for atheris.FuzzedDataProvider. Python-level exceptions are caught; only ASan/UBSan signal real bugs. CMake - MCRF_FUZZER=ON builds mcrfpy_fuzz from all src/*.cpp except main.cpp plus tests/fuzz/fuzz_common.cpp, linked with -fsanitize=fuzzer,address, undefined. Asset+lib post-build copy added so the embedded interpreter finds its stdlib and default_font/default_texture load. Makefile - fuzz-build builds only mcrfpy_fuzz (fast iterate) - fuzz loops over six targets setting MCRF_FUZZ_TARGET for each - fuzz-long TARGET=x SECONDS=n for deep manual runs - fuzz-repro TARGET=x CRASH=path for crash reproduction - Shared ASAN_OPTIONS / PYTHONHOME env via FUZZ_ENV define tests/fuzz - fuzz_common.cpp: LLVMFuzzerInitialize bootstraps Python, imports target, resolves fuzz_one_input. LLVMFuzzerTestOneInput wraps bytes as PyBytes, calls target, swallows Python errors. - fuzz_common.py: ByteStream byte consumer + safe_reset() + EXPECTED_EXCEPTIONS - Six target stubs (grid_entity, property_types, anim_timer_scene, maps_procgen, fov, pathfinding_behavior) to be fleshed out in follow-up - README with build/run/triage instructions Verified end-to-end: make fuzz-build produces build-fuzz/mcrfpy_fuzz, make fuzz FUZZ_SECONDS=3 ran all six targets (~2400-9800 exec/s each, 667-1883 coverage edges), make fuzz-repro loaded and replayed a corpus input cleanly. No crashes from the stubs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:05:04 -04:00
"""
import mcrfpy
from fuzz_common import ByteStream, EXPECTED_EXCEPTIONS
# --- Easing pool: pre-resolve enum members so dispatch is O(1) ----------
_EASING_NAMES = (
"LINEAR", "EASE_IN", "EASE_OUT", "EASE_IN_OUT",
"EASE_IN_QUAD", "EASE_OUT_QUAD", "EASE_IN_OUT_QUAD",
"EASE_IN_CUBIC", "EASE_OUT_CUBIC", "EASE_IN_OUT_CUBIC",
"EASE_IN_SINE", "EASE_OUT_SINE", "EASE_IN_OUT_SINE",
"EASE_IN_BACK", "EASE_OUT_BACK", "EASE_IN_OUT_BACK",
"EASE_IN_BOUNCE", "EASE_OUT_BOUNCE", "EASE_IN_OUT_BOUNCE",
"EASE_IN_ELASTIC", "EASE_OUT_ELASTIC",
"PING_PONG", "PING_PONG_SMOOTH",
)
_EASINGS = []
try:
_EasingEnum = getattr(mcrfpy, "Easing", None)
if _EasingEnum is not None:
for _n in _EASING_NAMES:
_v = getattr(_EasingEnum, _n, None)
if _v is not None:
_EASINGS.append(_v)
except Exception:
pass
# Properties that are numeric on most UIDrawable subtypes - safe to animate
# to a float target without tripping type validation.
_NUMERIC_PROPS = ("x", "y", "w", "h", "opacity", "sprite_index")
# --- Callback factories ---------------------------------------------------
#
# These are the DANGEROUS closures. Each captures references to scene-graph
# objects and fires at an unpredictable later time (timer interval elapsed,
# animation duration reached, scene swapped). If the engine has a stale
# pointer anywhere in that chain, ASan fires here.
def _noop_anim(target, prop, value):
pass
def _noop_timer(timer, runtime):
pass
class _AnimCallbackTouchesDrawables:
"""Animation completion callback that pokes at a list of drawables.
The drawables list is captured at animate() time; by the time the
callback fires, some of those drawables may have been removed from
their parent, reparented, or dropped from every python ref. Touching
.parent, .x, etc. probes whether the engine kept live pointers.
"""
__slots__ = ("_drawables",)
def __init__(self, drawables):
self._drawables = list(drawables)
def __call__(self, target, prop, value):
for d in self._drawables:
try:
# Force a property access; accessor round-trips through
# the PythonObjectCache (#269) and the C++ parent backref.
_ = d.x
_ = d.parent
except Exception:
pass
class _AnimCallbackCascades:
"""Animation callback that starts NEW animations on the same target.
Exercises cascading animation creation from inside an animation
completion handler - tests whether the animation system is
reentrant-safe when it fires callbacks.
"""
__slots__ = ("_depth",)
def __init__(self, depth=2):
self._depth = depth
def __call__(self, target, prop, value):
if self._depth <= 0:
return
try:
target.animate("x", 0.0, 0.05, _EASINGS[0] if _EASINGS else None,
callback=_AnimCallbackCascades(self._depth - 1))
except Exception:
pass
class _TimerSwapsScene:
"""Timer callback that swaps mcrfpy.current_scene to another scene.
This is the #269 (PythonObjectCache race) stress: while a timer fires,
the scene it was created under may be torn down and another scene
installed. Any C++ code that holds a parent_scene pointer should
cope, or we crash.
"""
__slots__ = ("_scenes",)
def __init__(self, scenes):
self._scenes = list(scenes)
def __call__(self, timer, runtime):
try:
if self._scenes:
mcrfpy.current_scene = self._scenes[-1]
except Exception:
pass
class _TimerMutatesTimers:
"""Timer callback that creates AND destroys timers mid-fire.
The timer queue is being walked by the engine when this fires; adding
and removing entries probes iterator-invalidation safety.
"""
__slots__ = ("_timers", "_scenes_ref")
def __init__(self, timers, scenes_ref):
self._timers = timers
self._scenes_ref = scenes_ref
def __call__(self, timer, runtime):
try:
# Destroy self if possible
try:
timer.stop()
except Exception:
pass
# Create a replacement timer
if len(self._timers) < 8:
new_t = mcrfpy.Timer("reentrant", _noop_timer, 50)
self._timers.append(new_t)
except Exception:
pass
class _TimerStartsAnimation:
"""Timer callback that kicks off new animations on captured drawables.
Tests the path where a drawable's python wrapper might still be live
but its C++ backing has been relocated or its parent has been swapped.
"""
__slots__ = ("_drawables",)
def __init__(self, drawables):
self._drawables = list(drawables)
def __call__(self, timer, runtime):
for d in self._drawables[:3]:
try:
easing = _EASINGS[0] if _EASINGS else None
d.animate("x", 10.0, 0.1, easing)
except Exception:
pass
# --- Helpers --------------------------------------------------------------
def _make_scene(stream, scenes):
if len(scenes) >= 3:
return
name = stream.ascii_str(8) or "fuzz_s"
s = mcrfpy.Scene(name)
scenes.append(s)
def _activate_scene(stream, scenes):
if not scenes:
return
s = stream.pick_one(scenes)
if s is not None:
mcrfpy.current_scene = s
def _make_frame(stream, frames, drawables):
if len(frames) >= 16:
return
x = float(stream.int_in_range(-50, 500))
y = float(stream.int_in_range(-50, 500))
w = float(stream.int_in_range(1, 400))
h = float(stream.int_in_range(1, 400))
f = mcrfpy.Frame(pos=(x, y), size=(w, h))
frames.append(f)
drawables.append(f)
def _make_caption(stream, drawables):
text = stream.ascii_str(12)
x = float(stream.int_in_range(-50, 500))
y = float(stream.int_in_range(-50, 500))
c = mcrfpy.Caption(text=text, pos=(x, y))
drawables.append(c)
return c
def _make_sprite(stream, drawables):
x = float(stream.int_in_range(-50, 500))
y = float(stream.int_in_range(-50, 500))
idx = stream.int_in_range(0, 255)
tex = getattr(mcrfpy, "default_texture", None)
if tex is None:
s = mcrfpy.Sprite(pos=(x, y))
else:
s = mcrfpy.Sprite(pos=(x, y), texture=tex, sprite_index=idx)
drawables.append(s)
return s
def _attach_to_scene(stream, scenes, drawables):
if not scenes or not drawables:
return
s = stream.pick_one(scenes)
d = stream.pick_one(drawables)
if s is None or d is None:
return
try:
s.children.append(d)
except Exception:
pass
def _nest_into_frame(stream, frames, drawables):
if not frames or not drawables:
return
parent = stream.pick_one(frames)
child = stream.pick_one(drawables)
if parent is None or child is None or parent is child:
return
try:
parent.children.append(child)
except Exception:
pass
def _reparent(stream, frames, scenes, drawables):
"""Remove a drawable from wherever it lives and append it to a new parent.
Exercises #270/#277: the engine must keep parent backrefs in sync
when a drawable moves between containers.
"""
if not drawables:
return
child = stream.pick_one(drawables)
if child is None:
return
# Try all parent containers; the one that contains child, remove it.
removed = False
for f in frames:
try:
f.children.remove(child)
removed = True
break
except Exception:
pass
if not removed:
for s in scenes:
try:
s.children.remove(child)
removed = True
break
except Exception:
pass
# Now append to a new random parent (Frame or Scene).
if frames and stream.bool():
target = stream.pick_one(frames)
elif scenes:
target = stream.pick_one(scenes)
else:
target = None
if target is None or target is child:
return
try:
target.children.append(child)
except Exception:
pass
def _animate_plain(stream, drawables):
if not drawables:
return
d = stream.pick_one(drawables)
if d is None:
return
prop = stream.pick_one(_NUMERIC_PROPS)
target = stream.float_in_range(-200.0, 500.0)
duration = stream.float_in_range(0.01, 2.0)
easing = stream.pick_one(_EASINGS) if _EASINGS else None
try:
if easing is None:
d.animate(prop, target, duration)
else:
d.animate(prop, target, duration, easing)
except Exception:
pass
def _animate_with_closure(stream, drawables):
"""Create an animation whose completion callback captures other
drawables by strong ref. When the callback fires, some captured
drawables may be gone from the tree.
"""
if not drawables:
return
d = stream.pick_one(drawables)
if d is None:
return
prop = stream.pick_one(_NUMERIC_PROPS)
target = stream.float_in_range(-100.0, 300.0)
duration = stream.float_in_range(0.01, 0.5)
easing = stream.pick_one(_EASINGS) if _EASINGS else None
n_captured = stream.int_in_range(1, min(4, len(drawables)))
captured = [stream.pick_one(drawables) for _ in range(n_captured)]
captured = [c for c in captured if c is not None]
cb = _AnimCallbackTouchesDrawables(captured)
try:
if easing is None:
d.animate(prop, target, duration, callback=cb)
else:
d.animate(prop, target, duration, easing, callback=cb)
except Exception:
pass
def _animate_cascading(stream, drawables):
if not drawables:
return
d = stream.pick_one(drawables)
if d is None:
return
depth = stream.int_in_range(1, 3)
cb = _AnimCallbackCascades(depth)
easing = _EASINGS[0] if _EASINGS else None
try:
if easing is None:
d.animate("y", 50.0, 0.05, callback=cb)
else:
d.animate("y", 50.0, 0.05, easing, callback=cb)
except Exception:
pass
def _make_timer_plain(stream, timers):
if len(timers) >= 8:
return
name = stream.ascii_str(6) or "t"
interval = stream.int_in_range(1, 500)
t = mcrfpy.Timer(name, _noop_timer, interval)
timers.append(t)
def _make_timer_swap(stream, timers, scenes):
if len(timers) >= 8 or len(scenes) < 2:
return
name = stream.ascii_str(6) or "sw"
interval = stream.int_in_range(1, 200)
cb = _TimerSwapsScene(scenes)
t = mcrfpy.Timer(name, cb, interval)
timers.append(t)
def _make_timer_mutate(stream, timers, scenes):
if len(timers) >= 8:
return
name = stream.ascii_str(6) or "mu"
interval = stream.int_in_range(1, 200)
cb = _TimerMutatesTimers(timers, scenes)
t = mcrfpy.Timer(name, cb, interval)
timers.append(t)
def _make_timer_anim(stream, timers, drawables):
if len(timers) >= 8 or not drawables:
return
name = stream.ascii_str(6) or "ta"
interval = stream.int_in_range(1, 200)
cb = _TimerStartsAnimation(drawables)
t = mcrfpy.Timer(name, cb, interval)
timers.append(t)
def _timer_transition(stream, timers):
if not timers:
return
t = stream.pick_one(timers)
if t is None:
return
op = stream.u8() % 5
try:
if op == 0:
t.stop()
elif op == 1:
t.pause()
elif op == 2:
t.resume()
elif op == 3:
t.restart()
else:
# Poke properties - may throw
_ = t.active
_ = t.paused
_ = t.stopped
_ = t.remaining
except Exception:
pass
def _step_time(stream):
dt = stream.float_in_range(0.0, 0.5)
try:
mcrfpy.step(dt)
except Exception:
pass
def _drop_refs(frames, timers, drawables):
"""Clear local python references to drawables/frames/timers.
After this, only the C++ side (scene graph, timer queue) still knows
about these objects. A subsequent step() that fires pending callbacks
will hit any dangling pointers.
"""
frames.clear()
timers.clear()
drawables.clear()
# --- Main dispatch --------------------------------------------------------
_NUM_OPS = 16
Add native libFuzzer fuzz harness for Python API, addresses #283 Pivots away from atheris (which lacks Python 3.14 support) to a single libFuzzer-linked executable that embeds CPython, registers mcrfpy, and dispatches each iteration to a Python fuzz_one_input(data: bytes) function loaded from tests/fuzz/fuzz_<target>.py by MCRF_FUZZ_TARGET env var. libFuzzer instruments the C++ engine code where all #258-#278 bugs live; Python drives the fuzzing logic via an in-house ByteStream replacement for atheris.FuzzedDataProvider. Python-level exceptions are caught; only ASan/UBSan signal real bugs. CMake - MCRF_FUZZER=ON builds mcrfpy_fuzz from all src/*.cpp except main.cpp plus tests/fuzz/fuzz_common.cpp, linked with -fsanitize=fuzzer,address, undefined. Asset+lib post-build copy added so the embedded interpreter finds its stdlib and default_font/default_texture load. Makefile - fuzz-build builds only mcrfpy_fuzz (fast iterate) - fuzz loops over six targets setting MCRF_FUZZ_TARGET for each - fuzz-long TARGET=x SECONDS=n for deep manual runs - fuzz-repro TARGET=x CRASH=path for crash reproduction - Shared ASAN_OPTIONS / PYTHONHOME env via FUZZ_ENV define tests/fuzz - fuzz_common.cpp: LLVMFuzzerInitialize bootstraps Python, imports target, resolves fuzz_one_input. LLVMFuzzerTestOneInput wraps bytes as PyBytes, calls target, swallows Python errors. - fuzz_common.py: ByteStream byte consumer + safe_reset() + EXPECTED_EXCEPTIONS - Six target stubs (grid_entity, property_types, anim_timer_scene, maps_procgen, fov, pathfinding_behavior) to be fleshed out in follow-up - README with build/run/triage instructions Verified end-to-end: make fuzz-build produces build-fuzz/mcrfpy_fuzz, make fuzz FUZZ_SECONDS=3 ran all six targets (~2400-9800 exec/s each, 667-1883 coverage edges), make fuzz-repro loaded and replayed a corpus input cleanly. No crashes from the stubs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:05:04 -04:00
def fuzz_one_input(data):
stream = ByteStream(data)
scenes = []
frames = []
timers = []
drawables = []
Add native libFuzzer fuzz harness for Python API, addresses #283 Pivots away from atheris (which lacks Python 3.14 support) to a single libFuzzer-linked executable that embeds CPython, registers mcrfpy, and dispatches each iteration to a Python fuzz_one_input(data: bytes) function loaded from tests/fuzz/fuzz_<target>.py by MCRF_FUZZ_TARGET env var. libFuzzer instruments the C++ engine code where all #258-#278 bugs live; Python drives the fuzzing logic via an in-house ByteStream replacement for atheris.FuzzedDataProvider. Python-level exceptions are caught; only ASan/UBSan signal real bugs. CMake - MCRF_FUZZER=ON builds mcrfpy_fuzz from all src/*.cpp except main.cpp plus tests/fuzz/fuzz_common.cpp, linked with -fsanitize=fuzzer,address, undefined. Asset+lib post-build copy added so the embedded interpreter finds its stdlib and default_font/default_texture load. Makefile - fuzz-build builds only mcrfpy_fuzz (fast iterate) - fuzz loops over six targets setting MCRF_FUZZ_TARGET for each - fuzz-long TARGET=x SECONDS=n for deep manual runs - fuzz-repro TARGET=x CRASH=path for crash reproduction - Shared ASAN_OPTIONS / PYTHONHOME env via FUZZ_ENV define tests/fuzz - fuzz_common.cpp: LLVMFuzzerInitialize bootstraps Python, imports target, resolves fuzz_one_input. LLVMFuzzerTestOneInput wraps bytes as PyBytes, calls target, swallows Python errors. - fuzz_common.py: ByteStream byte consumer + safe_reset() + EXPECTED_EXCEPTIONS - Six target stubs (grid_entity, property_types, anim_timer_scene, maps_procgen, fov, pathfinding_behavior) to be fleshed out in follow-up - README with build/run/triage instructions Verified end-to-end: make fuzz-build produces build-fuzz/mcrfpy_fuzz, make fuzz FUZZ_SECONDS=3 ran all six targets (~2400-9800 exec/s each, 667-1883 coverage edges), make fuzz-repro loaded and replayed a corpus input cleanly. No crashes from the stubs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:05:04 -04:00
try:
# Seed one scene and make it active so subsequent ops have a
# valid current_scene to mutate.
try:
first = mcrfpy.Scene("fuzz_atc")
scenes.append(first)
mcrfpy.current_scene = first
except EXPECTED_EXCEPTIONS:
pass
n_ops = stream.int_in_range(1, 48)
for _ in range(n_ops):
if stream.remaining < 1:
break
op = stream.u8() % _NUM_OPS
try:
if op == 0:
_make_scene(stream, scenes)
elif op == 1:
_activate_scene(stream, scenes)
elif op == 2:
_make_frame(stream, frames, drawables)
elif op == 3:
_make_caption(stream, drawables)
elif op == 4:
_make_sprite(stream, drawables)
elif op == 5:
_attach_to_scene(stream, scenes, drawables)
elif op == 6:
_nest_into_frame(stream, frames, drawables)
elif op == 7:
_reparent(stream, frames, scenes, drawables)
elif op == 8:
_animate_plain(stream, drawables)
elif op == 9:
_animate_with_closure(stream, drawables)
elif op == 10:
_animate_cascading(stream, drawables)
elif op == 11:
_make_timer_plain(stream, timers)
elif op == 12:
_make_timer_swap(stream, timers, scenes)
elif op == 13:
_make_timer_mutate(stream, timers, scenes)
elif op == 14:
_make_timer_anim(stream, timers, drawables)
elif op == 15:
_timer_transition(stream, timers)
except EXPECTED_EXCEPTIONS:
pass
# Periodically advance time so timers/animations fire BEFORE
# the scene is torn down. A single step() fires at most one
# timer event; multiple steps guarantee forward progress.
if (op & 3) == 0:
_step_time(stream)
# Final-phase: drop all local refs, then step again. Any callback
# that still fires after this must operate on objects whose only
# keep-alive was the C++ scene graph / timer queue. If any of
# #269/#270/#275/#277 reproduces, it's most likely here.
_drop_refs(frames, timers, drawables)
for _ in range(3):
_step_time(stream)
Add native libFuzzer fuzz harness for Python API, addresses #283 Pivots away from atheris (which lacks Python 3.14 support) to a single libFuzzer-linked executable that embeds CPython, registers mcrfpy, and dispatches each iteration to a Python fuzz_one_input(data: bytes) function loaded from tests/fuzz/fuzz_<target>.py by MCRF_FUZZ_TARGET env var. libFuzzer instruments the C++ engine code where all #258-#278 bugs live; Python drives the fuzzing logic via an in-house ByteStream replacement for atheris.FuzzedDataProvider. Python-level exceptions are caught; only ASan/UBSan signal real bugs. CMake - MCRF_FUZZER=ON builds mcrfpy_fuzz from all src/*.cpp except main.cpp plus tests/fuzz/fuzz_common.cpp, linked with -fsanitize=fuzzer,address, undefined. Asset+lib post-build copy added so the embedded interpreter finds its stdlib and default_font/default_texture load. Makefile - fuzz-build builds only mcrfpy_fuzz (fast iterate) - fuzz loops over six targets setting MCRF_FUZZ_TARGET for each - fuzz-long TARGET=x SECONDS=n for deep manual runs - fuzz-repro TARGET=x CRASH=path for crash reproduction - Shared ASAN_OPTIONS / PYTHONHOME env via FUZZ_ENV define tests/fuzz - fuzz_common.cpp: LLVMFuzzerInitialize bootstraps Python, imports target, resolves fuzz_one_input. LLVMFuzzerTestOneInput wraps bytes as PyBytes, calls target, swallows Python errors. - fuzz_common.py: ByteStream byte consumer + safe_reset() + EXPECTED_EXCEPTIONS - Six target stubs (grid_entity, property_types, anim_timer_scene, maps_procgen, fov, pathfinding_behavior) to be fleshed out in follow-up - README with build/run/triage instructions Verified end-to-end: make fuzz-build produces build-fuzz/mcrfpy_fuzz, make fuzz FUZZ_SECONDS=3 ran all six targets (~2400-9800 exec/s each, 667-1883 coverage edges), make fuzz-repro loaded and replayed a corpus input cleanly. No crashes from the stubs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:05:04 -04:00
except EXPECTED_EXCEPTIONS:
pass