Skip to content

bun:test: keep runtime-internal timeouts out of the fake timer heap - #37946

Merged
Jarred-Sumner merged 5 commits into
mainfrom
farm/b3e6d0d0/fake-timers-runtime-timeouts
Aug 13, 2026
Merged

bun:test: keep runtime-internal timeouts out of the fake timer heap#37946
Jarred-Sumner merged 5 commits into
mainfrom
farm/b3e6d0d0/fake-timers-runtime-timeouts

Conversation

@robobun

@robobun robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • jest.useFakeTimers() captures the runtime's own safety timeouts. With fake timers active, Bun.spawn({ timeout }) never kills the child, new SQL({ connectionTimeout }) / new RedisClient(url, { connectionTimeout }) never time out, and jest.getTimerCount() counts these timers as if the test had created them.
  • jest.useRealTimers() (and jest.clearAllTimers()) then drains the fake heap and disarms them permanently: a child spawned with timeout: 200 under fake timers runs to completion after the test is back on real time, and a Postgres connection attempt stays pending forever. The owner still believes it is armed, so nothing re-arms it.
  • Bun.spawnSync({ timeout }) under fake timers has the same problem without the heap: its wait loop compared the deadline against the mocked clock, which cannot advance while the call blocks.
  • Cause: Tag::allow_fake_timers() in src/event_loop/EventLoopTimer.rs was an opt-out list, so every owner not on it (SubprocessTimeout, the Postgres/MySQL/Valkey connection, idle, lifetime and reconnect timers, UpgradedDuplex, WindowsNamedPipe, DNSResolver, DateHeaderTimer, the dev server timers) was routed into the fake heap by All::insert (src/runtime/timer/mod.rs), and each of them computed its deadline with AllowMockedTime to match. The fake timers PR left this list as a TODO ("should subprocess timeout? probably not").
  • FakeTimers::clear() (src/runtime/test_runner/timers/FakeTimers.rs) only told TimeoutObject and AbortSignal.timeout() owners that their timer was gone; every other popped node was just marked CANCELLED.

Fix

  • allow_fake_timers() becomes an allowlist of the timers a program schedules itself: setTimeout/setInterval (TimeoutObject), AbortSignal.timeout(), and Bun.cron() (documented as honoring fake timers). Everything else stays in the real heap and keeps running on the real clock, which is also what Jest does (its fake timers never reach Node's internal timeouts).
  • Every owner that moves to the real heap now arms with ForceRealTime (subprocess, Postgres, MySQL, Valkey, UpgradedDuplex, WindowsNamedPipe, c-ares poll timer, Date header timer, dev server sweep / visualizer). This is required, not optional: the real heap is drained against the real clock (All::next), so a mocked deadline there would be due immediately. It is the same convention the existing opt-outs (BunTest, GcRepeating, StatWatcherScheduler, QuicEndpoint, DnsSdConnection) already follow.
  • spawnSync compares its timeout against the real clock too (js_bun_spawn_bindings.rs, SpawnSyncEventLoop.rs), and only consults an AbortSignal.timeout() deadline that lives in the real heap; one in the fake heap is on the mocked clock and cannot fire during a blocking call anyway. Behavior without fake timers is unchanged.
  • Bun.cron is now the only non-JS owner the fake heap can drop, so FakeTimers::clear() collects dropped jobs and stops them the way stop() would (CronJob::stop_dropped_from_fake_heap), matching what happens to a dropped setInterval. Before, a job created under fake timers and then useRealTimers() kept the process alive forever with a timer that could never fire. The fallback arm is now a debug_assert!, so adding a tag to the allowlist without a release path fails loudly in debug builds.
  • Behavior change to be aware of: advanceTimersByTime() no longer fires runtime-internal timeouts (e.g. a SQL connectionTimeout); they elapse in real time. No test or doc relied on that; the cron tests that do rely on mocking are unaffected. Not changed here: the async node:child_process timeout option is implemented with a JS setTimeout in src/js/node/child_process.ts, so it is still faked like any other setTimeout.
  • Verified with test/js/bun/test/fake-timers/fake-timers.test.ts (new runtime timeouts are not fake timers and Bun.cron() job dropped from the fake heap blocks, 9 tests, including node:child_process.spawnSync({ timeout }), which hands its timeout to Bun.spawnSync). All 9 fail on the unfixed build (getTimerCount() is 1 or 2, spawn/spawnSync children exit normally, the cron child hangs until killed) and pass with the fix.
  • Also passing on the debug build: test/js/bun/test/fake-timers/sinonjs, test-timers.test.ts, test/js/bun/cron/in-process-cron.test.ts and cron-local-time.test.ts, spawn-signal.test.ts (covers spawnSync + AbortSignal.timeout), spawnSync.test.ts, spawn-maxbuf.test.ts, spawnsync-isolated-event-loop.test.ts, spawnsync-no-microtask-drain.test.ts, test/js/sql/sql-connect-error-reporting.test.ts, the fake-timer cases of test/cli/test/isolation.test.ts, test/regression/issue/25869.test.ts, 26284.test.ts. cargo clippy on bun_event_loop, bun_runtime, bun_sql_jsc is clean, including --target x86_64-pc-windows-msvc for the WindowsNamedPipe change.
  • Overlap: bun:test: cover Bun.serve's Date header timer under fake timers #37935 (Date header timer) and test: cover the c-ares poll timer under jest.useFakeTimers() #37940 (c-ares poll timer) each add one of these owners to the old opt-out list and flip its clock; this PR contains both of those source changes as part of the allowlist. Their tests still apply on top of this and are worth keeping; whichever lands second needs a small rebase of allow_fake_timers().

Background

  • timer::All keeps two intrusive heaps of EventLoopTimer nodes. The event loop drains timers against CLOCK_MONOTONIC. While jest.useFakeTimers() is active, All::insert diverts nodes whose Tag allows it into fake_timers.timers, which only the jest.* functions drain: each pop sets the mocked clock to the node's deadline and fires it; useRealTimers() / clearAllTimers() pop everything without firing.
  • Tag says which struct embeds a node (a Subprocess, a PostgresSQLConnection, a TimeoutObject, ...); __bun_fire_timer in src/runtime/dispatch.rs recovers the owner from it. Owners detect their own cancellation by checking state == ACTIVE before removing, which is why a foreign cancellation is silent rather than a crash.
  • Timespec::now(AllowMockedTime) returns the fake-timers clock when one is installed; ForceRealTime always reads the monotonic clock. A deadline must be computed with the clock of the heap it is inserted into.
  • FakeTimers::clear() returns the owners of the popped nodes so they can be released after the heap borrow ends (TimeoutObject drops its heap ref, AbortSignal.timeout() is cancelled through its signal, and now a CronJob is stopped); a node that is only unlinked leaves its owner holding event-loop refs for a timer that will never fire.
  • spawnSync runs the child on an isolated mini event loop and blocks the JS thread in a tick loop, so no jest.advanceTimersByTime() can run while it waits; the only clock that can make its timeout elapse is the real one.
Repro on the released binary (1.4.0)
import { expect, jest, test } from "bun:test";

test("Bun.spawn timeout armed under fake timers, then useRealTimers()", async () => {
  jest.useFakeTimers();
  const p = Bun.spawn(["sleep", "3"], { timeout: 200, killSignal: "SIGKILL" });
  jest.useRealTimers();
  await p.exited;
  expect(p.signalCode).toBe("SIGKILL"); // null: child ran for 3 s
});

test("spawnSync timeout under fake timers", () => {
  jest.useFakeTimers();
  const r = Bun.spawnSync(["sleep", "3"], { timeout: 200, killSignal: "SIGKILL" });
  expect(r.exitedDueToTimeout).toBe(true); // undefined, and the call blocked for 3 s
});

test("cron job dropped by useRealTimers", () => {
  // as a script: the process never exits, the job holds the loop open with a dead timer
  jest.useFakeTimers();
  Bun.cron("* * * * *", () => {});
  jest.useRealTimers();
});

The SQL variant from the report (a Bun.listen that accepts and never answers, connectionTimeout: 1, let the connection get established, then useRealTimers()) stays pending past 4 s; with the fix it rejects with ERR_POSTGRES_CONNECTION_TIMEOUT after ~1 s.


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

fails on main (without fix)
ASAN without fix: 9 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 (ada0dd1be)

test/js/bun/test/fake-timers/fake-timers.test.ts:
(pass) fake timers [34.23ms]
(pass) advanceTimersToNextTimer > one setTimeout [11.19ms]
(pass) advanceTimersToNextTimer > setInterval [10.59ms]
(pass) advanceTimersToNextTimer > sorted timeouts [18.70ms]
(pass) advanceTimersToNextTimer > alternating intervals [12.93ms]
(pass) advanceTimersByTime > setInterval [9.41ms]
(pass) runOnlyPendingTimers > two setIntervals [12.40ms]
(pass) runAllTimers > two setIntervals [12.89ms]
(pass) getTimerCount > returns correct count of pending timers [10.69ms]
(pass) getTimerCount > throws error if fake timers not active [5.09ms]
(pass) clearAllTimers > clears all pending timers [6.90ms]
(pass) clearAllTimers > throws error if fake timers not active [3.88ms]
(pass) AbortSignal.timeout > pending signals stay alive while the fake heap holds their timer [653.89ms]
(pass) AbortSignal.timeout > useRealTimers() releases the signals whose timers it dropped [696.70ms]
(pass) AbortSign
... (truncated)

release without fix: 11 FAILED
bun test v1.4.0-canary.1 (da3851e57)

test/js/bun/test/fake-timers/fake-timers.test.ts:
(pass) fake timers [10.73ms]
(pass) advanceTimersToNextTimer > one setTimeout [0.29ms]
(pass) advanceTimersToNextTimer > setInterval [0.17ms]
(pass) advanceTimersToNextTimer > sorted timeouts [0.22ms]
(pass) advanceTimersToNextTimer > alternating intervals [0.13ms]
(pass) advanceTimersByTime > setInterval [0.15ms]
(pass) runOnlyPendingTimers > two setIntervals [0.17ms]
(pass) runAllTimers > two setIntervals [0.15ms]
(pass) getTimerCount > returns correct count of pending timers [0.12ms]
(pass) getTimerCount > throws error if fake timers not active [0.11ms]
(pass) clearAllTimers > clears all pending timers [0.10ms]
(pass) clearAllTimers > throws error if fake timers not active [0.04ms]
(pass) AbortSignal.timeout > pending signals stay alive while the fake heap holds their timer [12.11ms]
238 |   test("useRealTimers() releases the signals whose timers it dropped", () => {
239 |     const before = liveAbortSignals();
240 |     vi.useFakeTimers();
241 |     leakObservedTimeouts();
242 |     vi.useRealTimers();
243 |     expect(liveAbortSignals() - before).toBeLessThan(N * 0.1);
     
... (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 (ada0dd1be)

test/js/bun/test/fake-timers/fake-timers.test.ts:
(pass) fake timers [27.16ms]
(pass) advanceTimersToNextTimer > one setTimeout [7.74ms]
(pass) advanceTimersToNextTimer > setInterval [8.05ms]
(pass) advanceTimersToNextTimer > sorted timeouts [13.95ms]
(pass) advanceTimersToNextTimer > alternating intervals [8.95ms]
(pass) advanceTimersByTime > setInterval [7.16ms]
(pass) runOnlyPendingTimers > two setIntervals [7.82ms]
(pass) runAllTimers > two setIntervals [8.66ms]
(pass) getTimerCount > returns correct count of pending timers [7.35ms]
(pass) getTimerCount > throws error if fake timers not active [4.26ms]
(pass) clearAllTimers > clears all pending timers [4.93ms]
(pass) clearAllTimers > throws error if fake timers not active [2.71ms]
(pass) AbortSignal.timeout > pending signals stay alive while the fake heap holds their timer [619.24ms]
(pass) AbortSignal.timeout > useRealTimers() releases the signals whose timers it dropped [609.59ms]
(pass) AbortSignal.tim
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 827ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/1179] fetch hdrhistogram
[hdrhistogram] up to date
[2/1179] fetch lshpack
[lshpack] up to date
[3/1179] fetch lolhtml
[lolhtml] up to date
[4/1179] cc obj/vendor/zlib/deflate_slow.c.o
[5/1179] cc obj/vendor/zlib/insert_string_roll.c.o
[6/1179] cc obj/vendor/zlib/deflate_quick.c.o
[7/1179] cc obj/vendor/zlib/insert_string.c.o
[8/1179] cc obj/vendor/zlib/functable.c.o
[9/1179] cc obj/vendor/zlib/deflate_rle.c.o
[10/1179] cc obj/vendor/zlib/deflate_medium.c.o
[11/1179] cc obj/vendor/zlib/uncompr.c.o
[12/1179] cc obj/vendor/zlib/deflate_stored.c.o
[13/1179] cc obj/vendor/zlib/inftrees.c.o
[14/1179] cc obj/vendor/zlib/zutil.c.o
[15/1179] cc obj/vendor/zlib/cpu_features.c.o
[16/1179] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[16/1179] 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   Co
... (truncated)
diff hotspot
src/event_loop/EventLoopTimer.rs                 |  21 ++-
 src/event_loop/SpawnSyncEventLoop.rs             |   6 +-
 src/runtime/api/bun/js_bun_spawn_bindings.rs     |  13 +-
 src/runtime/api/cron.rs                          |  11 ++
 src/runtime/bake/dev_server/hmr_socket.rs        |   2 +-
 src/runtime/bake/dev_server/source_map_store.rs  |   2 +-
 src/runtime/dns_jsc/dns.rs                       |   2 +-
 src/runtime/socket/UpgradedDuplex.rs             |   2 +-
 src/runtime/socket/WindowsNamedPipe.rs           |   2 +-
 src/runtime/test_runner/timers/FakeTimers.rs     |  21 ++-
 src/runtime/timer/Timer.rs                       |   2 +-
 src/runtime/timer/mod.rs                         |   2 +-
 src/runtime/valkey_jsc/js_valkey.rs              |   2 +-
 src/sql_jsc/mysql/JSMySQLConnection.rs           |   4 +-
 src/sql_jsc/postgres/PostgresSQLConnection.rs    |   4 +-
 test/js/bun/test/fake-timers/fake-timers.test.ts | 160 +++++++++++++++++++++++
 16 files changed, 225 insertions(+), 31 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                              reads  edits  tests
src/event_loop/EventLoopTimer.rs                      1      4      0
src/event_loop/SpawnSyncEventLoop.rs                  1      4      0
src/runtime/api/bun/js_bun_spawn_bindings.rs          4      5      0
src/runtime/api/cron.rs                               3      2      0
src/runtime/bake/dev_server/hmr_socket.rs             1      1      0
src/runtime/bake/dev_server/source_map_store.rs       2      1      0
src/runtime/dns_jsc/dns.rs                            3      1      0
src/runtime/socket/UpgradedDuplex.rs                  1      1      0
src/runtime/socket/WindowsNamedPipe.rs                1      1      0
src/runtime/test_runner/timers/FakeTimers.rs          2      7      0
src/runtime/timer/Timer.rs                            3      1      0
src/runtime/timer/mod.rs                              6      1      0
src/runtime/valkey_jsc/js_valkey.rs                   1      1      0
src/sql_jsc/mysql/JSMySQLConnection.rs                1      2      0
src/sql_jsc/postgres/PostgresSQLConnection.rs         3      2      0
test/js/bun/test/fake-timers/fake-timers.test.ts      4      8      0

Tag::allow_fake_timers was a denylist, so every timer the runtime arms
for itself (Bun.spawn timeout, Postgres/MySQL/Valkey connection, idle,
lifetime and reconnect timers, UpgradedDuplex and named pipe timeouts,
the c-ares poll timer, the Date header timer, dev server sweeps) went
into the fake heap while jest.useFakeTimers() was active. There they
were counted by getTimerCount(), only fired through advanceTimersByTime(),
and were silently disarmed for good when useRealTimers() or
clearAllTimers() drained the heap: the child was never killed, the
connection attempt never timed out.

Make the set an allowlist of the timers a program schedules itself
(setTimeout/setInterval, AbortSignal.timeout(), Bun.cron), and arm every
other owner with ForceRealTime, since the real heap is drained against
the real clock. spawnSync's blocking wait compares against the real
clock for the same reason, and only consults an AbortSignal.timeout()
deadline that lives in the real heap.

Bun.cron is the one remaining non-JS timer the fake heap can drop.
Stop a dropped job like stop() would, instead of leaving it holding the
event loop open for a timer that can never fire.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The change restricts fake timers to timeout objects, abort-signal timeouts, and cron jobs. Runtime-managed timers use real time. Spawn timeout checks exclude mocked deadlines. Cron jobs removed from the fake heap receive explicit cleanup. Tests cover spawn, SQL, Redis, and cron behavior.

Fake timer scope and cleanup

Layer / File(s) Summary
Fake timer scope and cron cleanup
src/event_loop/EventLoopTimer.rs, src/runtime/api/cron.rs, src/runtime/test_runner/timers/FakeTimers.rs, test/js/bun/test/fake-timers/fake-timers.test.ts
Fake timers admit only selected timer tags. Cleared cron jobs are stopped after fake-heap removal. Tests verify cron cleanup and process exit.

Spawn timeout real-clock handling

Layer / File(s) Summary
Spawn timeout calculations and validation
src/event_loop/SpawnSyncEventLoop.rs, src/runtime/api/bun/js_bun_spawn_bindings.rs, test/js/bun/test/fake-timers/fake-timers.test.ts
Spawn timeout calculations and synchronous wait checks use real time. Mocked-heap abort deadlines do not control synchronous waits. Tests cover spawn timeout behavior.

Runtime-managed real-time timers

Layer / File(s) Summary
Runtime service timer scheduling
src/runtime/bake/dev_server/*, src/runtime/dns_jsc/dns.rs, src/runtime/socket/*, src/runtime/timer/*, src/runtime/valkey_jsc/js_valkey.rs, src/sql_jsc/mysql/JSMySQLConnection.rs, src/sql_jsc/postgres/PostgresSQLConnection.rs
Development-server, DNS, socket, date-header, Valkey, MySQL, and PostgreSQL timers now use forced real-time timestamps.

Possibly related PRs

  • oven-sh/bun#37604: Both changes update spawn timeout handling in js_bun_spawn_bindings.rs.
  • oven-sh/bun#37609: Both changes update AbortSignal timeout behavior during spawned-process waits.
  • oven-sh/bun#37940: Both changes update fake-timer admission and DNS resolver timer behavior.

Suggested reviewers: jarred-sumner, dylan-conway, cirospaciari

🚥 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 and concisely describes the main change: excluding runtime-internal timeouts from the fake timer heap.
Description check ✅ Passed The description explains the problem, fix, behavior change, verification steps, test results, and related work in sufficient detail.

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

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on the released 1.4.0 binary with the spawn, spawnSync (Bun and node:child_process), SQL, Redis and cron shapes described above (spawn child ran the full 3 s after useRealTimers(), getTimerCount() reported 1 or 2 with no user timers, the cron script never exited). The 9 new tests in test/js/bun/test/fake-timers/fake-timers.test.ts fail on the unfixed build and pass with this branch; related spawn, cron, SQL, sinonjs and isolation suites pass on the debug build. Waiting on CI.

Overlaps with #37935 and #37940, which each handle one of the owners covered here; see the PR description.

Comment thread src/event_loop/EventLoopTimer.rs Outdated
Comment thread src/event_loop/SpawnSyncEventLoop.rs Outdated
Comment thread src/runtime/api/bun/js_bun_spawn_bindings.rs Outdated
Comment thread src/runtime/api/cron.rs Outdated
Comment thread src/runtime/test_runner/timers/FakeTimers.rs Outdated
Comment thread src/runtime/test_runner/timers/FakeTimers.rs Outdated
Comment thread src/runtime/test_runner/timers/FakeTimers.rs Outdated
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:05 PM PT - Aug 12th, 2026

@robobun, your commit ada0dd1 is building: #93674

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Note for reviewers: the node:child_process sync APIs hit the same path. spawnSync, execSync and execFileSync pass options.timeout straight through to Bun.spawnSync (src/js/node/child_process.ts, the Bun.spawnSync({ ..., timeout: options.timeout }) call in spawnSync), so on 1.4.0 with jest.useFakeTimers() active, execSync("sleep 2", { timeout: 300 }) returns normally after ~2 s instead of throwing ETIMEDOUT, for the same reason as the Bun.spawnSync case above. The ForceRealTime change in spawn_maybe_sync / SpawnSyncEventLoop::tick_with_timeout covers it; an execSync case next to the Bun.spawnSync test would pin that down.

Repro on 1.4.0
import { expect, jest, test } from "bun:test";
import { execSync } from "node:child_process";

test("execSync({ timeout }) under fake timers", () => {
  jest.useFakeTimers();
  try {
    let err: any;
    try {
      execSync("sleep 2", { timeout: 300, stdio: "ignore" });
    } catch (e) {
      err = e;
    }
    expect(err?.code).toBe("ETIMEDOUT"); // undefined: returned normally after ~2005 ms
  } finally {
    jest.useRealTimers();
  }
});

@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: 2

🤖 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 `@test/js/bun/test/fake-timers/fake-timers.test.ts`:
- Around line 283-292: Update the sleepingChild workload to launch Bun with -e
and execute Bun.sleep(3000) instead of relying on the external sleep executable.
Preserve the existing timeout, killSignal, environment, and output settings so
the child remains alive beyond the timeout and exercises the intended path
portably.
- Around line 278-421: Add fake-timer regression tests covering socket timeout
behavior for both UpgradedDuplex and WindowsNamedPipe, verifying each timeout
expires using real time while fake timers are active and after useRealTimers().
Gate the WindowsNamedPipe coverage to Windows, and follow the existing
runtime-timeout test patterns and assertions.
🪄 Autofix

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: 60de6905-6458-4728-9d24-f9f69974f4ba

📥 Commits

Reviewing files that changed from the base of the PR and between 165dc9f and 4d3d567.

📒 Files selected for processing (16)
  • src/event_loop/EventLoopTimer.rs
  • src/event_loop/SpawnSyncEventLoop.rs
  • src/runtime/api/bun/js_bun_spawn_bindings.rs
  • src/runtime/api/cron.rs
  • src/runtime/bake/dev_server/hmr_socket.rs
  • src/runtime/bake/dev_server/source_map_store.rs
  • src/runtime/dns_jsc/dns.rs
  • src/runtime/socket/UpgradedDuplex.rs
  • src/runtime/socket/WindowsNamedPipe.rs
  • src/runtime/test_runner/timers/FakeTimers.rs
  • src/runtime/timer/Timer.rs
  • src/runtime/timer/mod.rs
  • src/runtime/valkey_jsc/js_valkey.rs
  • src/sql_jsc/mysql/JSMySQLConnection.rs
  • src/sql_jsc/postgres/PostgresSQLConnection.rs
  • test/js/bun/test/fake-timers/fake-timers.test.ts

Comment thread test/js/bun/test/fake-timers/fake-timers.test.ts
Comment thread test/js/bun/test/fake-timers/fake-timers.test.ts
Comment thread src/event_loop/EventLoopTimer.rs
Comment thread src/event_loop/SpawnSyncEventLoop.rs
Comment thread src/runtime/api/bun/js_bun_spawn_bindings.rs
Comment thread src/runtime/api/cron.rs
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

The spawnSync part of this PR is the same change as farm/70e2cfe5/spawnsync-timeout-fake-timers (main...farm/70e2cfe5/spawnsync-timeout-fake-timers): ForceRealTime in SpawnSyncEventLoop::tick_with_timeout and in the spawn_maybe_sync wait loop, and a fake-heap AbortSignal.timeout() left out of the deadline. Not opening a second PR for it.

That branch has two tests this PR does not, both confined to test files, so they cherry-pick cleanly on top of this one:

  • test/cli/test/test-timeout-behavior.test.ts + test/cli/test/process-kill-fixture-sync-fake-timers.ts: the bun:test per-test timeout still kills a spawnSync child while fake timers are active. The wait loop measured that deadline on the mocked clock as well, so a test with no timeout option and a child that never exits blocked the runner for good; on the unfixed build the fixture waits out its 5 s child and "killed 1 dangling process" is never printed.
  • test/js/bun/spawn/spawnSync.test.ts: an AbortSignal.timeout() created under fake timers does not cut the child short. This pins the in_heap check: with only the clock flips, the real-clock now is already past the fake-heap deadline and the child is killed immediately.

@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.

I reviewed this PR and didn't find any bugs. Because it inverts the fake-timer admission rule from opt-out to allowlist across ~15 timer owners, changes user-observable behavior (advanceTimersByTime no longer fires runtime timeouts), and overlaps with #37935/#37940, a human look would still be worthwhile.

What was reviewed:

  • Every tag moved off the allowlist now arms with ForceRealTime — including ValkeyConnectionReconnect, which shares RefCountedTimer::arm with the connection timer.
  • ImmediateObject returning false from the new allowlist is a no-op: immediates go through enqueue_immediate_task, never All::insert.
  • CronJob::stop_dropped_from_fake_heapself_stopstop_internal skips the heap remove because clear() already set state = CANCELLED; no double-remove.
  • The spawnSync in_heap == Regular gate correctly excludes both fake-heap and unlinked (None) AbortSignal.timeout deadlines.
Extended reasoning...

Overview

This PR inverts Tag::allow_fake_timers() in src/event_loop/EventLoopTimer.rs from an opt-out list (everything mockable except a handful of internal tags) to an explicit allowlist (TimeoutObject, AbortSignalTimeout, CronJob). Every timer owner that consequently moves to the real heap has its deadline computation flipped from AllowMockedTime to ForceRealTime (subprocess, Postgres, MySQL, Valkey, UpgradedDuplex, WindowsNamedPipe, c-ares poll, Date header, dev server sweep/visualizer). spawnSync's tick loop and its AbortSignal.timeout deadline probe are moved to the real clock, with a new in_heap == Regular filter. FakeTimers::clear gains a CronJob release arm and turns the wildcard into a debug_assert!. Nine new tests cover spawn/spawnSync (Bun and node:child_process), Postgres/MySQL/Redis connection timeouts, and Bun.cron under useRealTimers()/clearAllTimers().

Security risks

None identified. No auth, crypto, or untrusted-input parsing is touched; the change is which clock a set of internal deadlines are computed against and which heap they land in.

Level of scrutiny

Medium-high. The core mechanism is small and well-argued, but it is a semantic redesign of the fake-timer contract that touches 16 files across the event loop, timer subsystem, spawn, three SQL drivers, DNS, sockets, and the dev server. It deliberately changes user-observable behavior (advanceTimersByTime no longer fires SQL/Redis/subprocess timeouts) and the PR itself flags overlap with two sibling PRs (#37935, #37940) that will need rebase coordination. That combination — cross-cutting behavior change plus PR-ordering concerns — is exactly what a maintainer should sign off on rather than an automated approval.

Other factors

I traced the tag list against the diff: every owner leaving the allowlist has its arming clock flipped, including ValkeyConnectionReconnect (shares RefCountedTimer::arm) and both dev-server timers. ImmediateObject now returns false from allow_fake_timers() but is unaffected because immediates are enqueued via enqueue_immediate_task, not All::insert. The new CronJob release path is safe against double-remove: clear() sets state = CANCELLED and in_heap = None before self_stop runs, so stop_internal's state == ACTIVE guard skips the heap remove. The comment-cop and CodeRabbit threads on the timeline are all resolved (comments trimmed in 4d03cbd, sleeping child made portable, UpgradedDuplex/WindowsNamedPipe test request withdrawn). Test coverage is solid and the PR shows the 9 new cases failing on the unfixed build. No correctness issues found; deferring purely on scope and the design/coordination decision.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: #37936 moves the memory visualizer arm site (the ms_from_now(AllowMockedTime, 1000) line this PR changes in hmr_socket.rs) into DevServer::arm_memory_visualizer_timer in DevServer.rs, because it restores the empty tick handler that was leaving the timer node stale in the heap. Whichever of the two lands second needs to carry the ForceRealTime change across to that helper; it is a one-line rebase either way.

@Jarred-Sumner
Jarred-Sumner merged commit c4b0d91 into main Aug 13, 2026
38 of 39 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/b3e6d0d0/fake-timers-runtime-timeouts branch August 13, 2026 00:05
robobun added a commit that referenced this pull request Aug 13, 2026
Before #37946 the Date header timer was enrolled in jest.useFakeTimers():
accepting one connection under fake timers made jest.getTimerCount()
report 1 with no user timers, and jest.runAllTimers() never returned
because the timer re-armed itself into the fake heap on every pop while a
keep-alive connection was open. #37946 fixed the enrollment; these tests
pin the Bun.serve symptom, which its own tests do not exercise.
robobun added a commit that referenced this pull request Aug 13, 2026
#37946 moved the resolver's 1s poll timer (Tag::DNSResolver) out of the
fake timer heap and onto the real clock, but added no DNS coverage. These
tests pin that behavior: an in-flight query is not counted by
getTimerCount() and still times out after useRealTimers(), runAllTimers()
returns while a query is in flight, and the timer is armed against the
real clock even when the mocked clock has been pushed years ahead.

All of them fail on bun 1.4.0 (getTimerCount() is 1, the query never
settles, runAllTimers() spins until killed) and pass on main.
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