diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index ed4cbfb877ad..698192018b86 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1173,6 +1173,19 @@ impl VirtualMachine { let _ = self.event_loop_mut().drain_microtasks(); } + /// Set [`Self::suppress_microtask_drain`] = `true`, restore the prior value + /// on drop. Makes `drain_microtasks_with_global()` a no-op for the guard's + /// lifetime. + #[inline] + pub fn suppress_microtask_drain_scope(&'static self) -> SuppressMicrotaskDrain { + let prev = self.suppress_microtask_drain.replace(true); + SuppressMicrotaskDrain { + vm: self, + prev, + _not_send: core::marker::PhantomData, + } + } + /// Acquires the JSC API lock for the duration of `f()`. /// /// Routes `f` through `JSC__VM__holdAPILock` via an `OpaqueWrap`-style C @@ -1866,6 +1879,24 @@ bun_io::link_impl_EventLoopCtx! { } } +/// RAII guard returned by [`VirtualMachine::suppress_microtask_drain_scope`]. +#[must_use = "dropping immediately restores the prior suppress_microtask_drain value; bind to a named local"] +pub struct SuppressMicrotaskDrain { + vm: &'static VirtualMachine, + prev: bool, + // `VirtualMachine` is `unsafe impl Sync`, so `&'static VirtualMachine` would + // make this guard auto-`Send`/`Sync`; the `Cell` write in `Drop` must stay + // on the JS thread. + _not_send: core::marker::PhantomData<*mut ()>, +} + +impl Drop for SuppressMicrotaskDrain { + #[inline] + fn drop(&mut self) { + self.vm.suppress_microtask_drain.set(self.prev); + } +} + impl VirtualMachine { /// # Safety /// `this` must be a live VM (per-thread or a worker's parent ref) that diff --git a/src/jsc/event_loop.rs b/src/jsc/event_loop.rs index cf4669476d9a..7d14f0e9e592 100644 --- a/src/jsc/event_loop.rs +++ b/src/jsc/event_loop.rs @@ -669,14 +669,11 @@ impl EventLoop { let vm = self.vm(); // `Cell` swap through `&VirtualMachine` — no `&mut VM` formed (would // overlap `&mut self: EventLoop`, which is a value field of the VM). - let prev = self.vm_ref().suppress_microtask_drain.replace(true); + let _suppress = self.vm_ref().suppress_microtask_drain_scope(); while self.tick_with_count(vm) > 0 { self.tick_concurrent(); } - - self.vm_ref().suppress_microtask_drain.set(prev); - // Note: reshaped for borrowck — `defer vm.suppress_microtask_drain = prev` moved to tail } pub fn enqueue_task(&mut self, task: Task) { diff --git a/src/runtime/test_runner/timers/FakeTimers.rs b/src/runtime/test_runner/timers/FakeTimers.rs index 2b3f0b29b0cd..428ae7cf7b4b 100644 --- a/src/runtime/test_runner/timers/FakeTimers.rs +++ b/src/runtime/test_runner/timers/FakeTimers.rs @@ -316,10 +316,24 @@ fn use_real_timers(global: &JSGlobalObject, frame: &CallFrame) -> JsResult bun_jsc::virtual_machine::SuppressMicrotaskDrain { + global.bun_vm().suppress_microtask_drain_scope() +} + #[bun_jsc::host_fn] fn advance_timers_to_next_timer(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { error_unless_fake_timers(global)?; + let _suppress = suppress_microtask_drain(global); let _ = FakeTimers::execute_next(global); Ok(frame.this()) @@ -354,6 +368,7 @@ fn advance_timers_by_time(global: &JSGlobalObject, frame: &CallFrame) -> JsResul let effective_advance = if arg_number == 0.0 { 1.0 } else { arg_number }; let target = current.add_ms_float(effective_advance); + let _suppress = suppress_microtask_drain(global); FakeTimers::execute_until(global, target); CURRENT_TIME.set(global, &target, None); @@ -364,6 +379,7 @@ fn advance_timers_by_time(global: &JSGlobalObject, frame: &CallFrame) -> JsResul fn run_only_pending_timers(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { error_unless_fake_timers(global)?; + let _suppress = suppress_microtask_drain(global); FakeTimers::execute_only_pending_timers(global); Ok(frame.this()) @@ -373,6 +389,7 @@ fn run_only_pending_timers(global: &JSGlobalObject, frame: &CallFrame) -> JsResu fn run_all_timers(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { error_unless_fake_timers(global)?; + let _suppress = suppress_microtask_drain(global); FakeTimers::execute_all_timers(global); Ok(frame.this()) diff --git a/test/js/bun/test/fake-timers/fake-timers.test.ts b/test/js/bun/test/fake-timers/fake-timers.test.ts index 40ae5b1daae3..eee8963f91be 100644 --- a/test/js/bun/test/fake-timers/fake-timers.test.ts +++ b/test/js/bun/test/fake-timers/fake-timers.test.ts @@ -1,3 +1,4 @@ +import { bunEnv, bunExe, tempDir } from "harness"; import { afterEach, describe, expect, test, vi } from "vitest"; afterEach(() => vi.useRealTimers()); @@ -111,6 +112,103 @@ describe("advanceTimersToNextTimer", () => { vi.useRealTimers(); }); }); +describe.concurrent("sync APIs do not drain the microtask queue (jest parity)", () => { + // Jest's sync fake-timer APIs fire callbacks without flushing microtasks; a + // microtask scheduled inside a callback runs at the test's next real `await`, + // not before advanceTimersByTime() returns. The *Async variants opt into + // per-timer flushing. + // + // The ordering depends on event-loop enter/exit depth at the call site, which + // a prior test's real-time `await` can change, so assert in a subprocess. + async function runOrderingFixture({ + timerCount, + api, + expected, + }: { + timerCount: 1 | 2; + api: string; + expected: string[]; + }) { + const setups = Array.from( + { length: timerCount }, + (_, i) => + `setTimeout(() => { log.push("T${i + 1}"); Promise.resolve().then(() => log.push("P${i + 1}")); }, ${5 * (i + 1)});`, + ).join("\n "); + using dir = tempDir("fake-timers-sync-microtask", { + "ordering.test.ts": ` + import { jest, test } from "bun:test"; + test("ordering", async () => { + jest.useFakeTimers(); + const log = []; + ${setups} + jest.${api}; + log.push("after-advance"); + await Promise.resolve(); + log.push("after-await"); + console.log("ORDER=" + JSON.stringify(log)); + jest.useRealTimers(); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "ordering.test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const order = stdout.match(/^ORDER=(.*)$/m)?.[1]; + expect({ order, exitCode, stderr: exitCode === 0 ? undefined : stderr }).toEqual({ + order: JSON.stringify(expected), + exitCode: 0, + stderr: undefined, + }); + } + + test("advanceTimersByTime", async () => { + await runOrderingFixture({ + timerCount: 2, + api: "advanceTimersByTime(10)", + expected: ["T1", "T2", "after-advance", "P1", "P2", "after-await"], + }); + }); + + test("advanceTimersToNextTimer", async () => { + await runOrderingFixture({ + timerCount: 1, + api: "advanceTimersToNextTimer()", + expected: ["T1", "after-advance", "P1", "after-await"], + }); + }); + + test("runAllTimers", async () => { + await runOrderingFixture({ + timerCount: 2, + api: "runAllTimers()", + expected: ["T1", "T2", "after-advance", "P1", "P2", "after-await"], + }); + }); + + test("runOnlyPendingTimers", async () => { + await runOrderingFixture({ + timerCount: 2, + api: "runOnlyPendingTimers()", + expected: ["T1", "T2", "after-advance", "P1", "P2", "after-await"], + }); + }); + + test("microtask drain resumes after advanceTimersByTime returns", async () => { + vi.useFakeTimers(); + setTimeout(() => {}, 10); + vi.advanceTimersByTime(10); + let ran = false; + queueMicrotask(() => (ran = true)); + await Promise.resolve(); + expect(ran).toBe(true); + }); +}); + describe("advanceTimersByTime", () => { test("setInterval", () => { vi.useFakeTimers();