fix: stop nextAsync advanceUntilModeChanges from leaking past uninstall#571
fix: stop nextAsync advanceUntilModeChanges from leaking past uninstall#571fatso83 merged 4 commits intosinonjs:mainfrom
nextAsync advanceUntilModeChanges from leaking past uninstall#571Conversation
After clock.uninstall(), pauseAutoTickUntilFinished's .finally() could
still call setTickMode({ mode: "nextAsync" }), starting a new
advanceUntilModeChanges loop on the dead clock. Since nothing ever
increments the counter on an uninstalled clock, the loop spins forever,
keeping the Node process alive after the test suite completes.
Set clock.uninstalled = true before setTickMode("manual") in uninstall(),
and guard the restore in .finally() so it is a no-op on an uninstalled clock.
Fixes: sinonjs#564
Two test issues addressed: 1. Add regression test for issue sinonjs#564: tickAsync() called without await while in nextAsync mode caused an infinite advanceUntilModeChanges loop after uninstall(). The test triggers this by not awaiting tickAsync() and relying on afterEach to call uninstall() while the tick is in flight. 2. Fix the flaky race in "works with manual calls to async tick functions". Mocha inserts macrotask boundaries between beforeEach hooks and the test body, giving the AUMC time to pre-fire timers before runToLastAsync/ nextAsync/tickAsync are called. If AUMC fired timer 4 (which dynamically schedules timer 5) before runToLastAsync captured lastTimer, the test would unexpectedly see [1,2,3,4,5] instead of [1,2,3,4]. Fix: reset to "manual" mode in the inner beforeEach so timer setup is deterministic, then re-enable "nextAsync" as the first synchronous line of each test body — before any await — so AUMC has not had a chance to advance the clock when the async method is called. Relates to: sinonjs#562
There was a problem hiding this comment.
Pull request overview
This PR addresses two issues in the nextAsync tick mode implementation: (1) a process hang caused by an infinite loop when a clock is uninstalled while an async tick is still in flight, and (2) flaky tests caused by auto-ticking advancing time during test setup.
Changes:
- Prevent
pauseAutoTickUntilFinished(...).finally(...)from re-enablingnextAsyncafter the clock has been uninstalled. - Mark the clock as uninstalled during
uninstall()to support the above guard. - Stabilize
setTickMode/nextAsynctests by pausing auto-ticking during timer setup and re-enabling it at the start of each test body, plus add a regression test for #564.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/fake-timers-src.js |
Adds an uninstalled flag and skips restoring nextAsync tick mode after uninstall to avoid infinite loops/hangs. |
test/fake-timers-test.js |
Adds a regression test for uninstall-during-tickAsync and makes nextAsync-mode async tick tests deterministic. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
nextAsync advanceUntilModeChanges from leaking past uninstall
|
Hmm, copilot's comment is a good spot. I wonder if we can move things around to avoid the new property |
| /** | ||
| * @returns {Timer[]} | ||
| */ | ||
| clock.uninstall = function () { |
There was a problem hiding this comment.
implementation is the same - just moved to close over uninstalled
| clock.setTickMode({ mode: "manual" }); | ||
| return promise.finally(() => { | ||
| clock.setTickMode({ mode: "nextAsync" }); | ||
| if (!uninstalled) { |
| uninstalled = true; | ||
| clock.setTickMode({ mode: "manual" }); |
There was a problem hiding this comment.
these two lines are new - otherwise the same as before
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Might want to do a rebase to catch any type regressions 😛 |
|
merged in main. still passing 🥳 |
|
Two biggos in one evening. Nais. |
|
I think a test broke that this was supposed to fix? https://github.com/sinonjs/fake-timers/actions/runs/25405330189/job/74514771134 |
@fatso83 hey again! I chucked Claude at #562 and it believes it found it, plus the #564 error. Here's its summary:
Summary
Fixes the process-hang bug (#564) and the flaky
nextAsynctest failures (#562).Bug 1 — infinite loop after
uninstall()(#564)pauseAutoTickUntilFinishedsaves the current tick mode and restores it in.finally()when the async operation settles. The problem: ifclock.uninstall()runs before.finally()fires (which happens whenever a timer callback callsdone()or resolves a test promise — causing the test to finish andafterEachto uninstall the clock while the un-awaitedtickAsyncis still in flight), the.finally()callssetTickMode({ mode: "nextAsync" })on the now-dead clock. This starts a newadvanceUntilModeChangesloop whose counter can never change, spinning forever and keeping the process alive after the suite exits.Fix: move
clock.uninstallintocreateClockso it can close over a privatelet uninstalled = falseflag. Settinguninstalled = trueat the top ofclock.uninstallis enough:pauseAutoTickUntilFinished's.finally()checks!uninstalledbefore restoringnextAsyncmode.Bug 2 — flaky
runToLastAsyncassertion (#562)Mocha inserts macrotask boundaries between
beforeEachhooks and before running the test body. The outerbeforeEachcallssetTickMode({ mode: "nextAsync" }), startingadvanceUntilModeChanges(AUMC). Each time Mocha yields to schedule the next hook or the test body, AUMC can complete another iteration and callclock.next(). If AUMC fires timer 4 (at T=5, which dynamically schedules timer 5 at T=6) before the test body reachesrunToLastAsync(), thenlastTimerreturns T=6 — andrunToLastAsyncincludes timer 5 in its run, producing[1,2,3,4,5]instead of the expected[1,2,3,4].Fix: in the inner
beforeEach, reset to"manual"mode before adding timers so setup is in a known state. Then re-enable"nextAsync"as the first synchronous line of each test body — before anyawait— so AUMC has not had a chance to advance the clock when the async method is called.