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>
533 lines
16 KiB
Python
533 lines
16 KiB
Python
"""fuzz_anim_timer_scene - Wave 2 / W6 target for #283.
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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:
|
|
# 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
|