fix: animations modifying animations during callback is now safe

This commit is contained in:
John McCardle 2026-02-04 10:25:59 -05:00
commit d2ea64bc32
2 changed files with 70 additions and 12 deletions

View file

@ -977,15 +977,22 @@ void AnimationManager::addAnimation(std::shared_ptr<Animation> animation,
switch (conflict_mode) { switch (conflict_mode) {
case AnimationConflictMode::REPLACE: case AnimationConflictMode::REPLACE:
// Complete the existing animation and replace it
if (existingAnim) { if (existingAnim) {
if (isUpdating) {
// During update, just stop the animation without completing
// to avoid recursive callback issues and iterator invalidation.
// The update loop will clean up stopped animations.
existingAnim->stop();
} else {
// Outside update loop, complete the animation (jump to final value)
// and remove it safely
existingAnim->complete(); existingAnim->complete();
// Remove from active animations
activeAnimations.erase( activeAnimations.erase(
std::remove(activeAnimations.begin(), activeAnimations.end(), existingAnim), std::remove(activeAnimations.begin(), activeAnimations.end(), existingAnim),
activeAnimations.end() activeAnimations.end()
); );
} }
}
// Fall through to add the new animation // Fall through to add the new animation
break; break;
@ -1046,21 +1053,26 @@ void AnimationManager::update(float deltaTime) {
// Add any animations that were created during update // Add any animations that were created during update
if (!pendingAnimations.empty()) { if (!pendingAnimations.empty()) {
// Re-add pending animations through addAnimation to handle conflicts properly
for (auto& anim : pendingAnimations) { for (auto& anim : pendingAnimations) {
if (anim && anim->hasValidTarget()) { if (anim && anim->hasValidTarget()) {
// Check if this was a queued animation or a new one
void* target = getAnimationTarget(anim); void* target = getAnimationTarget(anim);
std::string property = anim->getTargetProperty(); std::string property = anim->getTargetProperty();
PropertyKey key{target, property}; PropertyKey key{target, property};
// If not already locked, add it // Check if this animation is already the property lock holder
// (this happens when addAnimation was called during update)
auto lockIt = propertyLocks.find(key); auto lockIt = propertyLocks.find(key);
if (lockIt == propertyLocks.end() || lockIt->second.expired()) { bool isLockHolder = (lockIt != propertyLocks.end()) &&
(lockIt->second.lock() == anim);
bool propertyFree = (lockIt == propertyLocks.end()) ||
lockIt->second.expired();
if (isLockHolder || propertyFree) {
// This animation owns the lock or property is free - add it
propertyLocks[key] = anim; propertyLocks[key] = anim;
activeAnimations.push_back(anim); activeAnimations.push_back(anim);
} else { } else {
// Property still locked, re-queue // Property still locked by another animation, re-queue
animationQueue.emplace_back(key, anim); animationQueue.emplace_back(key, anim);
} }
} }

View file

@ -0,0 +1,46 @@
# Regression test: Recursive animation callbacks
# Issue: Animation callbacks that started new animations caused segfault in AnimationManager::update
# Fixed by: Deferring animation removal during update loop iteration
#
# This test verifies that:
# 1. Animation callbacks can start new animations on the same property (REPLACE mode)
# 2. No segfault occurs from iterator invalidation
# 3. The animation chain completes properly
import mcrfpy
import sys
scene = mcrfpy.Scene("test")
mcrfpy.current_scene = scene
frame = mcrfpy.Frame(pos=(100, 100), size=(50, 50), fill_color=mcrfpy.Color(255, 0, 0))
scene.children.append(frame)
callback_count = 0
MAX_CALLBACKS = 10
def recursive_callback(target, prop, value):
"""Animation callback that starts another animation on the same property"""
global callback_count
callback_count += 1
if callback_count >= MAX_CALLBACKS:
print(f"PASS - {callback_count} recursive animation callbacks completed without segfault")
sys.exit(0)
# Chain another animation - this used to cause segfault due to iterator invalidation
target.animate("x", 100 + (callback_count * 20), 0.1, mcrfpy.Easing.LINEAR, callback=recursive_callback)
# Start the chain
frame.animate("x", 200, 0.1, mcrfpy.Easing.LINEAR, callback=recursive_callback)
def timeout_check(timer, runtime):
"""Safety timeout"""
if callback_count >= MAX_CALLBACKS:
print(f"PASS - {callback_count} callbacks completed")
sys.exit(0)
else:
print(f"FAIL - only {callback_count}/{MAX_CALLBACKS} callbacks executed")
sys.exit(1)
safety_timer = mcrfpy.Timer("safety", timeout_check, 5000, once=True)