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:
parent
90a2945a9f
commit
56eeab2ff5
6 changed files with 519 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
BIN
tests/fuzz/seeds/anim_timer_scene/seed_animate_callback.bin
Normal file
BIN
tests/fuzz/seeds/anim_timer_scene/seed_animate_callback.bin
Normal file
Binary file not shown.
BIN
tests/fuzz/seeds/anim_timer_scene/seed_cascade.bin
Normal file
BIN
tests/fuzz/seeds/anim_timer_scene/seed_cascade.bin
Normal file
Binary file not shown.
BIN
tests/fuzz/seeds/anim_timer_scene/seed_drop_refs.bin
Normal file
BIN
tests/fuzz/seeds/anim_timer_scene/seed_drop_refs.bin
Normal file
Binary file not shown.
BIN
tests/fuzz/seeds/anim_timer_scene/seed_nested.bin
Normal file
BIN
tests/fuzz/seeds/anim_timer_scene/seed_nested.bin
Normal file
Binary file not shown.
BIN
tests/fuzz/seeds/anim_timer_scene/seed_timer_swap.bin
Normal file
BIN
tests/fuzz/seeds/anim_timer_scene/seed_timer_swap.bin
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue