fix(runtime): keep the animation scheduler alive when a tick callback throws (#194)

onTick set `isDispatching = true`, ran the subscriber callbacks, then reset the
flag, flushed `pending`, and rescheduled with no try/finally. A throwing callback
skipped all three, leaving `isDispatching` stuck true forever: every later
subscribe/unsubscribe queued into `pending` and never ran, and no timer was ever
rescheduled. One bad tick permanently killed every `useAnimation` instance sharing
the (process-wide) scheduler — a non-recoverable wedge.

Wrap the dispatch loop in try/finally so the scheduler invariants are always
restored and the error still propagates (restore-then-rethrow, mirroring
scheduler.ts `doCommit`). Also advance each subscriber's `nextDueTime` BEFORE
invoking its callback, so a thrower can't leave it in the past and make the
post-throw schedule() re-arm a 0ms tight re-throw loop.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-15 02:12:25 +08:00
committed by GitHub
parent bf9d9d4a4a
commit 0431870bb2
2 changed files with 58 additions and 11 deletions
@@ -152,6 +152,39 @@ describe.sequential("createAnimationScheduler", () => {
s.dispose();
});
test("a throwing subscriber callback does not wedge the shared scheduler", () => {
// Regression: onTick set `isDispatching = true`, ran the subscriber
// callbacks, then reset the flag / flushed `pending` / rescheduled with NO
// try/finally. A throwing callback skipped all three, leaving isDispatching
// stuck true forever — every later subscribe/unsubscribe queued into
// `pending` and never ran, and no timer was ever rescheduled. One bad tick
// permanently killed EVERY useAnimation instance sharing this scheduler.
const s = createAnimationScheduler();
const boom = vi.fn(() => {
throw new Error("boom in tick");
});
const boomHandle = s.subscribe(boom, 50);
// The throw propagates out of the timer callback (house idiom: restore the
// scheduler invariants, then rethrow — mirrors scheduler.ts doCommit). That
// is expected; what must NOT happen is the scheduler wedging.
expect(() => vi.advanceTimersByTime(50)).toThrow("boom in tick");
expect(boom).toHaveBeenCalledTimes(1);
// Remove the thrower (must run synchronously — proves isDispatching was
// reset; on the buggy code this op was queued into `pending` and never ran).
boomHandle.unsubscribe();
// A subscriber added after the throw must still tick. On the buggy code its
// add() was queued into `pending` (isDispatching never reset) and no timer
// was ever scheduled, so it never fired.
const recovered = vi.fn();
s.subscribe(recovered, 50);
vi.advanceTimersByTime(50);
expect(recovered).toHaveBeenCalledTimes(1);
s.dispose();
});
test("dispose clears everything", () => {
const s = createAnimationScheduler();
s.subscribe(vi.fn(), 50);
+25 -11
View File
@@ -68,18 +68,32 @@ export function createAnimationScheduler(renderThrottleMs = 0): AnimationSchedul
scheduledDueTime = Number.POSITIVE_INFINITY;
const now = performance.now();
isDispatching = true;
for (const s of subscribers) {
if (s.cancelled || now < s.nextDueTime) continue;
s.callback(now);
if (s.cancelled) continue;
const elapsedFrames = Math.floor((now - s.startTime) / s.interval) + 1;
s.nextDueTime = s.startTime + elapsedFrames * s.interval;
try {
for (const s of subscribers) {
if (s.cancelled || now < s.nextDueTime) continue;
// Advance nextDueTime BEFORE invoking the callback. If the callback
// throws, leaving nextDueTime in the past would make the post-throw
// schedule() re-arm a 0ms timer that re-fires and re-throws on a tight
// loop. A subscriber that cancels itself inside the callback is filtered
// by the cancelled checks here and in schedule(), so pre-advancing a
// then-cancelled sub is harmless (matches the old post-callback advance).
const elapsedFrames = Math.floor((now - s.startTime) / s.interval) + 1;
s.nextDueTime = s.startTime + elapsedFrames * s.interval;
s.callback(now);
}
} finally {
// A throwing subscriber callback must never wedge the SHARED scheduler.
// Without this finally, isDispatching stays true forever: every later
// subscribe/unsubscribe queues into `pending` and never runs, and no timer
// is rescheduled — one bad tick silently kills every useAnimation instance
// app-wide. Restore the invariants here (mirroring scheduler.ts doCommit's
// try/finally) and let the error propagate — restore, then rethrow.
isDispatching = false;
if (pending.length > 0) {
for (const op of pending.splice(0)) op();
}
schedule();
}
isDispatching = false;
if (pending.length > 0) {
for (const op of pending.splice(0)) op();
}
schedule();
}
function subscribe(callback: (currentTime: number) => void, intervalRaw: number) {