Skip to content

Under "nextAsync" tick mode, using tickAsync() without awaiting can cause Mocha process to hang after suite completion #564

@ericyhwang

Description

@ericyhwang

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.

  1. 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" });
      });
      }
  2. A timer execution causes the test case to finish immediately, e.g. via calling done().
  3. afterEach runs clock.uninstall().
  4. 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.
  5. However, now inside pauseAutoTickUntilFinished() above, the promise.finally() runs, which calls setTickMode("nextAsync") on the uninstalled clock.
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions