From bb3b7a9f2907ad404c38f8824bce874e3c54e45a Mon Sep 17 00:00:00 2001 From: Eric Fulton Date: Wed, 1 Jul 2026 17:59:54 -0700 Subject: [PATCH] Fix collection-modified crash in ManualMotionDispatcher when scheduling during Update ManualMotionDispatcher.Update and Reset enumerated the runners dictionary directly. A motion callback invoked during iteration (Bind/OnComplete/ OnCancel/loop callbacks) can schedule the first motion of a new type, which lazily adds a runner via GetOrCreateRunner and mutates the dictionary mid-enumeration, throwing "InvalidOperationException: Collection was modified; enumeration operation may not execute." Iterate a reused buffer snapshot of runners.Values instead of the live dictionary. The buffer is reused across calls and filled via AddRange's ICollection.CopyTo fast path, so this remains allocation-free after warmup. --- .../Runtime/ManualMotionDispatcher.cs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/LitMotion/Assets/LitMotion/Runtime/ManualMotionDispatcher.cs b/src/LitMotion/Assets/LitMotion/Runtime/ManualMotionDispatcher.cs index e7708667..036134f0 100644 --- a/src/LitMotion/Assets/LitMotion/Runtime/ManualMotionDispatcher.cs +++ b/src/LitMotion/Assets/LitMotion/Runtime/ManualMotionDispatcher.cs @@ -39,6 +39,14 @@ public sealed class ManualMotionDispatcher readonly ManualMotionDispatcherScheduler scheduler; readonly Dictionary runners = new(); + // Reused buffer so Update/Reset iterate a snapshot of the runners instead of the live + // dictionary. A motion callback fired during iteration can schedule the first motion of a + // new type, which lazily adds a runner (see GetOrCreateRunner) and would otherwise throw + // "InvalidOperationException: Collection was modified" mid-enumeration. Reused rather than + // allocated per call; AddRange(runners.Values) copies via ICollection.CopyTo and the + // Dictionary caches its ValueCollection, so this stays allocation-free after warmup. + readonly List runnerBuffer = new(); + public ManualMotionDispatcher() { scheduler = new(this); @@ -79,10 +87,13 @@ public void Update(double deltaTime) { time += deltaTime; - foreach (var kv in runners) + runnerBuffer.Clear(); + runnerBuffer.AddRange(runners.Values); + for (int i = 0; i < runnerBuffer.Count; i++) { - kv.Value.Update(time, time, time); + runnerBuffer[i].Update(time, time, time); } + runnerBuffer.Clear(); } /// @@ -90,10 +101,13 @@ public void Update(double deltaTime) /// public void Reset() { - foreach (var kv in runners) + runnerBuffer.Clear(); + runnerBuffer.AddRange(runners.Values); + for (int i = 0; i < runnerBuffer.Count; i++) { - kv.Value.Reset(); + runnerBuffer[i].Reset(); } + runnerBuffer.Clear(); time = 0; }