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) <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-04-10 11:16:40 -04:00
commit 56eeab2ff5
6 changed files with 519 additions and 7 deletions

View file

@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.