Skip to content

bun:test fake timers: don't drain microtasks in the sync APIs - #35496

Closed
robobun wants to merge 6 commits into
mainfrom
farm/4e1e9d50/fake-timers-sync-no-microtask-drain
Closed

robobun wants to merge 6 commits into
mainfrom
farm/4e1e9d50/fake-timers-sync-no-microtask-drain

Conversation

@robobun

@robobun robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

What

jest.advanceTimersByTime() (and advanceTimersToNextTimer, runAllTimers, runOnlyPendingTimers) no longer drain the microtask queue while firing timer callbacks. This matches Jest 30 / @sinonjs/fake-timers, where only the *Async variants flush microtasks between timers.

Repro

import { test, jest } from "bun:test";
test("advanceTimersByTime microtask ordering", async () => {
  jest.useFakeTimers();
  const log: string[] = [];
  setTimeout(() => { log.push("T1"); Promise.resolve().then(() => log.push("P1")); }, 10);
  jest.advanceTimersByTime(10);
  log.push("after-advance10");
  await Promise.resolve();
  log.push("after-await");
  console.log("ORDER " + JSON.stringify(log));
  jest.useRealTimers();
});

Before: ORDER ["T1","P1","after-advance10","after-await"]
Jest 30.4.1: ORDER ["T1","after-advance10","P1","after-await"]
After: ORDER ["T1","after-advance10","P1","after-await"]

Cause

Each fake timer fires through TimerObjectInternals::fire, which wraps the callback in event_loop().enter() / exit(). The test body itself is not inside an enter/exit pair (run_callback_with_result_and_forcefully_drain_microtasks calls the test callback without one), so exit() sees entered_event_loop_count == 1 and calls drain_microtasks() after every fired timer.

Fix

Set vm.suppress_microtask_drain = true for the duration of the four sync fake-timer host functions, restored via RAII drop. drain_microtasks_with_global() already early-returns on that flag (it's the same mechanism spawnSync uses). The guard saves/restores the prior value so nesting is preserved.

Verification

$ bun bd test test/js/bun/test/fake-timers/fake-timers.test.ts
 36 pass
 0 fail

All existing fake-timer users (test-timers.test.ts, in-process-cron.test.ts, cron-local-time.test.ts, regression 25869/26284) still pass.


[review] gate passed · iteration 1 · 4 files touched

fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/test/fake-timers/fake-timers.test.ts
bun test v1.4.0 (f2271f9ce)

test/js/bun/test/fake-timers/fake-timers.test.ts:
(pass) fake timers [25.52ms]
(pass) advanceTimersToNextTimer > one setTimeout [7.73ms]
(pass) advanceTimersToNextTimer > setInterval [6.97ms]
(pass) advanceTimersToNextTimer > sorted timeouts [11.55ms]
(pass) advanceTimersToNextTimer > alternating intervals [8.52ms]
(pass) sync APIs do not drain the microtask queue (jest parity) > microtask drain resumes after advanceTimersByTime returns [7.48ms]
157 |       stdout: "pipe",
158 |       stderr: "pipe",
159 |     });
160 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
161 |     const order = stdout.match(/^ORDER=(.*)$/m)?.[1];
162 |     expect({ order, exitCode, stderr: exitCode === 0 ? undefined : stderr }).toEqual({
                                                                                   ^
error: expect(received).toEqual(expected)

  {
    "exitCode": 0,
-   "order": "["T1","T2","afte
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (e2574e63f)

test/js/bun/test/fake-timers/fake-timers.test.ts:
(pass) fake timers [13.57ms]
(pass) advanceTimersToNextTimer > one setTimeout [0.20ms]
(pass) advanceTimersToNextTimer > setInterval [0.10ms]
(pass) advanceTimersToNextTimer > sorted timeouts [0.14ms]
(pass) advanceTimersToNextTimer > alternating intervals [0.09ms]
(pass) sync APIs do not drain the microtask queue (jest parity) > microtask drain resumes after advanceTimersByTime returns [0.22ms]
(pass) sync APIs do not drain the microtask queue (jest parity) > advanceTimersByTime [49.52ms]
(pass) sync APIs do not drain the microtask queue (jest parity) > runOnlyPendingTimers [13.02ms]
(pass) sync APIs do not drain the microtask queue (jest parity) > runAllTimers [15.03ms]
(pass) sync APIs do not drain the microtask queue (jest parity) > advanceTimersToNextTimer [22.03ms]
(pass) advanceTimersByTime > setInterval [0.25ms]
(pass) runOnlyPendingTimers > two setIntervals [0.21ms]
(pass) runAllTimers > two setIntervals [0.13ms]
(pass) getTimerCount > returns correct count of pending timers [0.13ms]
(pass) getTimerCount > throws error if fake timers not active [0.06ms]
(pass) clearAllTi
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/test/fake-timers/fake-timers.test.ts
bun test v1.4.0 (f2271f9ce)

test/js/bun/test/fake-timers/fake-timers.test.ts:
(pass) fake timers [25.40ms]
(pass) advanceTimersToNextTimer > one setTimeout [7.34ms]
(pass) advanceTimersToNextTimer > setInterval [6.94ms]
(pass) advanceTimersToNextTimer > sorted timeouts [11.60ms]
(pass) advanceTimersToNextTimer > alternating intervals [8.76ms]
(pass) sync APIs do not drain the microtask queue (jest parity) > microtask drain resumes after advanceTimersByTime returns [7.36ms]
(pass) sync APIs do not drain the microtask queue (jest parity) > advanceTimersByTime [364.47ms]
(pass) sync APIs do not drain the microtask queue (jest parity) > advanceTimersToNextTimer [308.91ms]
(pass) sync APIs do not drain the microtask queue (jest parity) > runAllTimers [322.12ms]
(pass) sync APIs do not drain the microtask queue (jest parity) > runOnlyPendingTimers [339.27ms]
(pass) advanceTimersByTime > setInterval [6.88ms]
(pass) runOnlyPendingTimers > two setIntervals [7.32ms]
(pass) runAllTimers > two setI
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1202ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 237 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_output 
... (truncated)
diff hotspot
src/jsc/VirtualMachine.rs                        | 31 ++++++++
 src/jsc/event_loop.rs                            |  5 +-
 src/runtime/test_runner/timers/FakeTimers.rs     | 17 ++++
 test/js/bun/test/fake-timers/fake-timers.test.ts | 98 ++++++++++++++++++++++++
 4 files changed, 147 insertions(+), 4 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                              reads  edits  tests
src/jsc/VirtualMachine.rs                             8      8      0
src/jsc/event_loop.rs                                 5      2      0
src/runtime/test_runner/timers/FakeTimers.rs          3     13      0
test/js/bun/test/fake-timers/fake-timers.test.ts      2      6      0

jest.advanceTimersByTime(), advanceTimersToNextTimer(), runAllTimers() and
runOnlyPendingTimers() are the sync fake-timer APIs. In Jest (via
@sinonjs/fake-timers) they fire each due callback without flushing the
microtask queue; a Promise scheduled inside a callback settles at the test's
next real await. The *Async variants opt into per-timer flushing.

Bun fired each fake timer through TimerObjectInternals::fire, which wraps the
callback in event_loop().enter()/exit(). The test body itself is not inside an
enter/exit pair, so exit() saw entered_event_loop_count == 1 and drained
microtasks after every fired timer. A migrated Jest suite asserting
"callback ran but its .then hasn't yet" observed a different (still
deterministic) order.

Set vm.suppress_microtask_drain for the duration of the four sync host
functions so the inner exit() becomes a no-op. The guard restores the prior
value on drop so nesting (e.g. inside spawnSync) is preserved.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The change adds scoped VM microtask-drain suppression, uses it during event-loop and synchronous fake-timer execution, and adds subprocess tests for Jest-compatible callback and microtask ordering.

Microtask suppression

Layer / File(s) Summary
VM suppression scope
src/jsc/VirtualMachine.rs
Adds an RAII guard that enables microtask-drain suppression and restores the previous value when dropped.
Execution path integration
src/jsc/event_loop.rs, src/runtime/test_runner/timers/FakeTimers.rs
Uses the suppression scope while ticking tasks and executing synchronous fake-timer APIs.
Fake-timer ordering validation
test/js/bun/test/fake-timers/fake-timers.test.ts
Adds subprocess-based tests covering callback and Promise microtask ordering across synchronous timer APIs, plus post-call microtask execution.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: preventing microtask drains in synchronous fake-timer APIs.
Description check ✅ Passed The description covers what changed, why, and how it was verified, matching the template’s required information.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:27 PM PT - Jul 24th, 2026

❌ @robobun, your commit f2271f9 has 1 failures in Build #79999 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35496

That installs a local version of the PR into your bun-35496 executable, so you can run:

bun-35496 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline nit, I also checked: the guard in advance_timers_by_time is armed only after argument validation, so the early-return error paths never touch the flag; drain_microtasks_with_global() (event_loop.rs:321) does honor suppress_microtask_drain; and there are no *Async fake-timer variants in-tree yet, so the four sync host functions are the complete set.

Extended reasoning...

The mechanism is sound and the tests cover each affected API plus the guard-drop restore. Deferring rather than approving because this is a user-observable ordering change to Jest-compat semantics — worth a human sign-off on the compat trade-off and on whether the guard dedup should happen before merge.

Comment thread src/runtime/test_runner/timers/FakeTimers.rs Outdated
robobun and others added 2 commits July 25, 2026 01:53
…g in a subprocess

Define the guard once as VirtualMachine::suppress_microtask_drain_scope() and
reuse it in FakeTimers and EventLoop::tick_tasks_only (the erased-pointer
SpawnSyncEventLoop copy keeps its FFI shim for crate layering).

The in-process ordering assertions depended on the first test in the file to
run without a prior real-time await; once an earlier test suspends through the
event loop, subsequent tests run at enter/exit depth >= 1 and the eager drain
is already skipped. Run the ordering check in a fresh bun test subprocess so it
is independent of what ran before it.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/VirtualMachine.rs`:
- Around line 1176-1183: Update the SuppressMicrotaskDrain guard to include a
PhantomData<*mut ()> marker, preventing automatic Send and Sync implementations.
Initialize the marker in suppress_microtask_drain_scope while preserving the
existing vm and prev fields and drop behavior.

In `@test/js/bun/test/fake-timers/fake-timers.test.ts`:
- Around line 139-154: Update runOrderingFixture and the inline
advanceTimersToNextTimer subprocess test to assert stdout, stderr, and exitCode
through one combined result object after the existing concurrent Promise.all
drain. Preserve the current expected values and ordering while replacing the
separate expect calls in both locations with a single combined assertion.
- Around line 160-188: Refactor the advanceTimersToNextTimer test to use the
shared runOrderingFixture helper instead of duplicating tempDir creation,
Bun.spawn, stream draining, and exit assertions. Parameterize runOrderingFixture
as needed to supply this test’s ordering fixture and expected log output, while
preserving the existing assertions and behavior of the three sibling tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b0f2d278-5fdd-4dbe-a918-92780f4454aa

📥 Commits

Reviewing files that changed from the base of the PR and between ae4b17d and c1111b3.

📒 Files selected for processing (4)
  • src/jsc/VirtualMachine.rs
  • src/jsc/event_loop.rs
  • src/runtime/test_runner/timers/FakeTimers.rs
  • test/js/bun/test/fake-timers/fake-timers.test.ts

Comment thread src/jsc/VirtualMachine.rs
Comment thread test/js/bun/test/fake-timers/fake-timers.test.ts Outdated
Comment thread test/js/bun/test/fake-timers/fake-timers.test.ts Outdated
…lper

VirtualMachine is unsafe impl Sync, so the guard would auto-derive Send/Sync
and could be dropped off-thread; add a PhantomData<*mut ()> to pin it to the
JS thread.

Parameterize runOrderingFixture(timerCount, api, expected) so all four
subprocess tests share one spawn path, and assert the extracted order line and
exit code as a single object (stderr surfaces only on non-zero exit).
Comment thread src/jsc/VirtualMachine.rs
autofix-ci Bot and others added 2 commits July 25, 2026 02:11
Matches the other RAII scope guards in bun_jsc (AutoGcOnDrop, MacroModeGuard,
EventLoopEnterGuard); an unbound call would set and immediately restore the
flag in the same statement.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — all prior feedback (guard dedup onto VirtualMachine, !Send marker, #[must_use], test helper dedup, combined subprocess assertion) has been addressed.

What was reviewed:

  • SuppressMicrotaskDrain guard: save/restore via Cell::replace/set preserves nesting; #[must_use] + PhantomData<*mut ()> match sibling guards in the file.
  • event_loop.rs::tick_tasks_only refactor is behaviorally identical to the prior inline replace/set (and now restore-on-unwind safe); vm_ref() returns &'static so the guard doesn't conflict with &mut self.
  • Guard placement in each host fn: created after all ? validation, dropped at scope end; CURRENT_TIME.set inside the scope doesn't enter/exit the event loop.
  • describe.concurrent: the four subprocess tests don't touch in-process fake timers, so the fifth in-process test running concurrently is isolated.
Extended reasoning...

Overview

Four sync fake-timer host functions (advanceTimersByTime, advanceTimersToNextTimer, runAllTimers, runOnlyPendingTimers) now hold a suppress_microtask_drain RAII guard while firing callbacks, matching Jest 30 / sinon fake-timers semantics where only the *Async variants flush microtasks between timers. The guard is a new public helper on VirtualMachine (per earlier review), also adopted at the pre-existing inline site in EventLoop::tick_tasks_only. Subprocess-based ordering tests cover all four APIs plus a "drain resumes after return" check.

Security risks

None. Test-runner-only code path; no untrusted input parsing, no auth/crypto, no I/O. The Cell<bool> is per-VM state written on the JS thread only (enforced by the PhantomData<*mut ()> marker).

Level of scrutiny

Medium-low. This is a user-visible ordering change, but (a) it uses the existing suppress_microtask_drain mechanism already proven by spawnSync, (b) it's scoped strictly to the four sync fake-timer host functions inside bun:test, not the general runtime, and (c) it moves toward Jest parity rather than away from it. The event_loop.rs hunk is a pure refactor (manual replace/set → RAII) with identical semantics.

Other factors

Two prior review rounds (mine and CodeRabbit's) surfaced five items — guard duplication, auto-Send, missing #[must_use], test boilerplate dedup, combined subprocess assertion — all addressed in e2574e6/a644a6d/f2271f9 and marked resolved. The PR description reports the full fake-timers suite (36 pass) and downstream fake-timer users (test-timers, cron tests, regressions 25869/26284) still passing. The tests correctly use tempDir/bunEnv/bunExe, drain pipes concurrently, and assert a combined {order, exitCode, stderr} object.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI on build #79999: fake-timers.test.ts passes on all lanes. The remaining failures are unrelated to this diff:

  • test/js/bun/util/filesystem_router.test.ts segfault at 0x35 on ubuntu 25.04 aarch64 in "reload() while Bun.build() resolves the same directory" (bundler/resolver path; reported for main-break triage)
  • test/napi/napi.test.ts and test/js/web/fetch/fetch-leak.test.ts both flaky, passed on retry

Ready for review.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-25, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants