Ran into an interesting issue while trying to upgrade the main sharedb library from sinon@15 (@sinonjs/fake-timers@10.3.0) to the latest sinon@21 (@sinonjs/fake-timers@15.3.x).
I was experimenting with the new setTickMode({mode: 'nextAsync'}) to resolve post-upgrade test timeouts. While that helped the test cases to no longer time out, I ran into a different issue where the Mocha Node process was left hanging after test suite completion.
A bunch of debugging later, including pointing Gemini at the issue to help me dig into the fake-timers code, I think the cause was using clock.TickAsync() without awaiting/then-ing the promise. (I did write this issue and repro without LLM assistance, hope it's not too wordy.)
It could just be user error with using clock.tickAsync() without awaiting/then-ing the promise. I probably should've caught that when reviewing those tests initially, but it feels like the clock shouldn't get stuck in an infinite loop after uninstall() regardless. 😅
Simple repro
Note that why-is-node-running is optional, I included it in case you want to see the open timers yourself.
Save it somewhere, run npm i --no-save mocha @sinonjs/fake-timers why-is-node-running, then npx mocha $FILE.
const whyIsNodeRunning = require('why-is-node-running').default;
const FakeTimers = require('@sinonjs/fake-timers');
describe('repros for nextAsync + tickAsync hanging Mocha', () => {
let clock;
beforeEach(() => {
clock = FakeTimers.install();
clock.setTickMode({mode: 'nextAsync'});
});
afterEach(() => {
clock.uninstall();
});
after(() => {
setImmediate(() => whyIsNodeRunning());
});
// This causes the Mocha process to hang after test suite completion
it.only('tickAsync = setTimeout', (done) => {
setTimeout(() => done(), 500);
clock.tickAsync(500);
});
});
The issue also manifests when tickAsync is greater than the timeout. It does not manifest when tickAsync < timeout, nor does it manifest when using the synchronous tick().
I ran this repro under Node v24.11.1, but I also saw hangs with Node 20 and 22 in the sharedb CI runs.
Probable explanation
Code permalinks here point to the latest commit as of 15.13.1, published earlier today.
tickAsync() temporarily sets the tick mode to "manual" in pauseAutoTickUntilFinished(), then runs waiting timers.
|
function pauseAutoTickUntilFinished(promise) { |
|
if (clock.tickMode.mode !== "nextAsync") { |
|
return promise; |
|
} |
|
clock.setTickMode({ mode: "manual" }); |
|
return promise.finally(() => { |
|
clock.setTickMode({ mode: "nextAsync" }); |
|
}); |
|
} |
- A timer execution causes the test case to finish immediately, e.g. via calling
done().
afterEach runs clock.uninstall().
- The test case completes, and Mocha prints the suite summary since there are no more test cases. Normally, the Node process would automatically exit at this point.
- However, now inside
pauseAutoTickUntilFinished() above, the promise.finally() runs, which calls setTickMode("nextAsync") on the uninstalled clock.
- That final
setTickMode("nextAsync") calls advanceUntilModeChanges(), and the end of that function enters into the infinite async loop below, since the clock is uninstalled and no longer changes tickMode.counter.
|
const { counter } = clock.tickMode; |
|
while (clock.tickMode.counter === counter) { |
|
await newMacrotask(); |
|
if (clock.tickMode.counter !== counter) { |
|
return; |
|
} |
|
clock.next(); |
I verified in a debugger that Node in fact gets infinitely stuck in that loop.
Fixes?
For my case, switching fully to clock.tick() resolves the issue, but like I mentioned above, fake-timers could potentially be more defensive to prevent the infinite loop, especially for cases that legitimately need tickAsync().
The Gemini-suggested fix was to set a clock.uninstalled = true flag on uninstall(), and in pauseAutoTickUntilFinished(), only run setTickMode("nextAsync") if the clock is not uninstalled. That did resolve the issue for me, both in the isolated repro and in the full sharedb test suite.
However, I'm wondering... would it make more sense to update setTickMode() to check if the clock has been uninstalled, and early return? Or maybe throw an error in that case? (That would also require the pauseAutoTickUntilFinished() guard above.)
I'd be willing to make a PR if needed, since I'm doing a bunch of open-source work right now anyways.
Ran into an interesting issue while trying to upgrade the main sharedb library from
sinon@15(@sinonjs/fake-timers@10.3.0) to the latestsinon@21(@sinonjs/fake-timers@15.3.x).I was experimenting with the new
setTickMode({mode: 'nextAsync'})to resolve post-upgrade test timeouts. While that helped the test cases to no longer time out, I ran into a different issue where the Mocha Node process was left hanging after test suite completion.A bunch of debugging later, including pointing Gemini at the issue to help me dig into the fake-timers code, I think the cause was using
clock.TickAsync()without awaiting/then-ing the promise. (I did write this issue and repro without LLM assistance, hope it's not too wordy.)It could just be user error with using
clock.tickAsync()without awaiting/then-ing the promise. I probably should've caught that when reviewing those tests initially, but it feels like the clock shouldn't get stuck in an infinite loop afteruninstall()regardless. 😅Simple repro
Note that
why-is-node-runningis optional, I included it in case you want to see the open timers yourself.Save it somewhere, run
npm i --no-save mocha @sinonjs/fake-timers why-is-node-running, thennpx mocha $FILE.The issue also manifests when tickAsync is greater than the timeout. It does not manifest when tickAsync < timeout, nor does it manifest when using the synchronous
tick().I ran this repro under Node v24.11.1, but I also saw hangs with Node 20 and 22 in the sharedb CI runs.
Probable explanation
Code permalinks here point to the latest commit as of 15.13.1, published earlier today.
tickAsync()temporarily sets the tick mode to "manual" inpauseAutoTickUntilFinished(), then runs waiting timers.fake-timers/src/fake-timers-src.js
Lines 1854 to 1862 in 7cda826
done().afterEachrunsclock.uninstall().pauseAutoTickUntilFinished()above, thepromise.finally()runs, which callssetTickMode("nextAsync")on the uninstalled clock.setTickMode("nextAsync")callsadvanceUntilModeChanges(), and the end of that function enters into the infinite async loop below, since the clock is uninstalled and no longer changestickMode.counter.fake-timers/src/fake-timers-src.js
Lines 1839 to 1845 in 7cda826
I verified in a debugger that Node in fact gets infinitely stuck in that loop.
Fixes?
For my case, switching fully to
clock.tick()resolves the issue, but like I mentioned above, fake-timers could potentially be more defensive to prevent the infinite loop, especially for cases that legitimately needtickAsync().The Gemini-suggested fix was to set a
clock.uninstalled = trueflag onuninstall(), and inpauseAutoTickUntilFinished(), only runsetTickMode("nextAsync")if the clock is not uninstalled. That did resolve the issue for me, both in the isolated repro and in the full sharedb test suite.However, I'm wondering... would it make more sense to update
setTickMode()to check if the clock has been uninstalled, and early return? Or maybe throw an error in that case? (That would also require thepauseAutoTickUntilFinished()guard above.)I'd be willing to make a PR if needed, since I'm doing a bunch of open-source work right now anyways.