From 56eeab2ff5ff31277808d9fbda16f49bda033c3b Mon Sep 17 00:00:00 2001 From: John McCardle Date: Fri, 10 Apr 2026 11:16:40 -0400 Subject: [PATCH] Add fuzz_anim_timer_scene target for lifecycle bug family, addresses #283 Targets #269 (PythonObjectCache race), #270 (GridLayer dangling parent), #275 (UIEntity missing tp_dealloc), #277 (GridChunk dangling parent). Exercises timer/animation callbacks that mutate scene and drawable lifetimes across firing boundaries, including scene swap mid-callback and closure captures that can survive past their target's lifetime. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/fuzz/fuzz_anim_timer_scene.py | 526 +++++++++++++++++- .../seed_animate_callback.bin | Bin 0 -> 44 bytes .../seeds/anim_timer_scene/seed_cascade.bin | Bin 0 -> 14 bytes .../seeds/anim_timer_scene/seed_drop_refs.bin | Bin 0 -> 34 bytes .../seeds/anim_timer_scene/seed_nested.bin | Bin 0 -> 42 bytes .../anim_timer_scene/seed_timer_swap.bin | Bin 0 -> 31 bytes 6 files changed, 519 insertions(+), 7 deletions(-) create mode 100644 tests/fuzz/seeds/anim_timer_scene/seed_animate_callback.bin create mode 100644 tests/fuzz/seeds/anim_timer_scene/seed_cascade.bin create mode 100644 tests/fuzz/seeds/anim_timer_scene/seed_drop_refs.bin create mode 100644 tests/fuzz/seeds/anim_timer_scene/seed_nested.bin create mode 100644 tests/fuzz/seeds/anim_timer_scene/seed_timer_swap.bin diff --git a/tests/fuzz/fuzz_anim_timer_scene.py b/tests/fuzz/fuzz_anim_timer_scene.py index 451477f..eeb329d 100644 --- a/tests/fuzz/fuzz_anim_timer_scene.py +++ b/tests/fuzz/fuzz_anim_timer_scene.py @@ -1,11 +1,22 @@ -"""fuzz_anim_timer_scene - stub. Wave 2 agent W6 will implement. +"""fuzz_anim_timer_scene - Wave 2 / W6 target for #283. -Target bugs: #269 (PythonObjectCache race), #270 (GridLayer dangling), -#275 (UIEntity missing tp_dealloc), #277 (GridChunk dangling). Random -animation create/step/callback, timer start/stop/pause/resume/restart, -Frame nesting and reparenting, scene swap mid-callback. +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 -Contract: define fuzz_one_input(data: bytes) -> None. +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. """ import mcrfpy @@ -13,9 +24,510 @@ 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 + + def fuzz_one_input(data): stream = ByteStream(data) + scenes = [] + frames = [] + timers = [] + drawables = [] + try: - mcrfpy.Scene("anim_stub") + # 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) + except EXPECTED_EXCEPTIONS: pass diff --git a/tests/fuzz/seeds/anim_timer_scene/seed_animate_callback.bin b/tests/fuzz/seeds/anim_timer_scene/seed_animate_callback.bin new file mode 100644 index 0000000000000000000000000000000000000000..27505d3aff7c44b6a8d274fd2c887a5bc69bb5fb GIT binary patch literal 44 qcmb1SVqi!CLM9^y5M=hqWB>scE(R_JR!#<{6F^Q15Hs_!^8o-^!2_xQ literal 0 HcmV?d00001 diff --git a/tests/fuzz/seeds/anim_timer_scene/seed_cascade.bin b/tests/fuzz/seeds/anim_timer_scene/seed_cascade.bin new file mode 100644 index 0000000000000000000000000000000000000000..f73aad82f20664442eb6f3e62d96e5afd80b57a9 GIT binary patch literal 14 Tcmd;KVqi!CLM{eoK0ZDG2y_7s literal 0 HcmV?d00001 diff --git a/tests/fuzz/seeds/anim_timer_scene/seed_drop_refs.bin b/tests/fuzz/seeds/anim_timer_scene/seed_drop_refs.bin new file mode 100644 index 0000000000000000000000000000000000000000..8d229fff8e84525958162803b1436d622863d87f GIT binary patch literal 34 hcmb1R;^JapkOM*{5fKQ3g#iedxl0Tgj2O5Wm;f8(0n7jZ literal 0 HcmV?d00001 diff --git a/tests/fuzz/seeds/anim_timer_scene/seed_nested.bin b/tests/fuzz/seeds/anim_timer_scene/seed_nested.bin new file mode 100644 index 0000000000000000000000000000000000000000..eb936fca08bd95b41366f5780e0df6470c8b3bad GIT binary patch literal 42 scmWe(;^JapFk;YPU=k5wV2}esW{*q;5g^URz{tVCn!*5*W?@tj2QU%_y9vA1LXh! literal 0 HcmV?d00001