Skip to content

test: cover the c-ares poll timer under jest.useFakeTimers() - #37940

Open
robobun wants to merge 1 commit into
mainfrom
farm/e18019da/dns-resolver-timer-fake-timers
Open

test: cover the c-ares poll timer under jest.useFakeTimers()#37940
robobun wants to merge 1 commit into
mainfrom
farm/e18019da/dns-resolver-timer-fake-timers

Conversation

@robobun

@robobun robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Re-scoped to tests only: #37946 landed while this was open and contains the two source lines this PR originally carried (Tag::DNSResolver no longer fakeable, Resolver::add_timer arming with ForceRealTime). It added no DNS coverage, so this PR now just pins that behavior for the resolver. The original description is in the details block at the bottom.

Problem

  • On bun 1.4.0, under jest.useFakeTimers(), the resolver's 1 s c-ares poll timer went into the fake heap: jest.getTimerCount() was 1 with a single dns.resolve*() (or, on Linux/macOS, dns.lookup()) in flight, jest.runAllTimers() spun at 100% CPU re-arming it, and a query started under fake timers never settled, even after jest.useRealTimers() (which discards the fake heap, poll timer included).
  • Since bun:test: keep runtime-internal timeouts out of the fake timer heap #37946 (allow_fake_timers() is an allowlist, add_timer reads the real clock) this is fixed on main, but nothing in the tree exercises the DNS resolver under fake timers, so either half (the tag routing or the clock add_timer reads) could regress silently.

Change

  • test/js/bun/test/test-timers.test.ts, new describe("c-ares poll timer under useFakeTimers"), next to the existing real-heap-under-fake-timers test. Both tests use a dns.promises.Resolver pointed at a local UDP socket that never answers, so nothing leaves the machine:
    • In process: getTimerCount() stays 0 with a query in flight, and after useRealTimers() the query still rejects with ETIMEOUT about a second later (c-ares floors the timeout at 250 ms; the poll runs once a second).
    • test-timers-dns-resolver-fixture.ts, run as a bun test child: runAllTimers() returns immediately with a query kept alive for ~50 s (tries: 10, since c-ares caps a try at 5 s), and a second query started after the mocked clock was pushed years past uptime still rejects with ETIMEOUT in real time. The second case fails if add_timer ever reads the mocked clock again, even with the tag routing intact, because the deadline would land years out in the real heap. The child is bounded by a spawn timeout, and the test's own timeout is set above it so a spinning child is reaped rather than orphaned.
  • Why resolve4() is enough: every request path except the Windows libuv lookup arms this one timer through request_sent -> add_timer (c-ares resolve/reverse/getaddrinfo/getnameinfo, the libc work-pool lookups, dns_sd on macOS), so one entry point covers the routing and the clock. A hermetic lookup() variant is not possible, since the libc and dns_sd backends always use the system resolver.
  • Verified: both tests pass on main at b7a777e (debug build, no source changes); both fail on the released 1.4.0 binary (Received: 1; the child spins until the spawn timeout kills it, 19.5 s of CPU in 20 s), and failed the same way on the debug build of the parent of the original fix before bun:test: keep runtime-internal timeouts out of the fake timer heap #37946 landed. This is coverage for an already-merged fix, so there is no unfixed main build for these to fail against.

Background

  • timer::All keeps two heaps of EventLoopTimer nodes. The event loop drains timers against CLOCK_MONOTONIC; while fake timers are active, All::insert diverts nodes whose Tag::allow_fake_timers() is true into the fake heap, which only the jest.* functions drain (each pop moves the mocked clock to the popped deadline). useRealTimers() / clearAllTimers() pop that heap without firing anything.
  • The c-ares poll timer: c-ares computes retransmit and timeout deadlines from its own monotonic clock; Bun calls ares_process_fd(ARES_SOCKET_BAD, ARES_SOCKET_BAD) once a second while anything is pending so c-ares gets to act on them. The node is armed by request_sent / request_completed (clock read in add_timer) and re-armed from check_timeouts with the now the heap drain passes in, so both the heap it lands in and the clock add_timer reads have to be the real ones.
Original description (source fix, superseded by #37946)

Problem

  • Under jest.useFakeTimers(), jest.getTimerCount() reports 1 as soon as a node:dns resolve*() query is in flight, with no user timers created. The same timer is armed by every other request the resolver sends (request_sent callers in dns.rs: c-ares getaddrinfo/reverse/lookupService, the libc work-pool lookups, and dns_sd.rs on macOS), so dns.lookup() on Linux and macOS shows the same count; only the Windows libuv lookup path does not use it.
  • jest.runAllTimers() in that state does not return: the process sits at 100% CPU in a synchronous loop, so the per-test timeout cannot fire either. For a c-ares query it ends once c-ares gives the query up (5 s per try, times tries); for a libc or dns_sd lookup it cannot end on its own.
  • Without a drain, a dns.resolve*() started under fake timers never settles at all, and a later jest.useRealTimers() does not help, since it discards the fake heap, poll timer included.
  • Cause: Tag::DNSResolver was not on the Tag::allow_fake_timers() opt-out list in src/event_loop/EventLoopTimer.rs, so All::insert put the node in the fake heap; FakeTimers::execute_all_timers is while execute_next() {}, each pop ran check_timeouts -> ares_process_fd, c-ares judged the query by its own real clock, and add_timer re-armed at mocked now + 1 s. add_timer also read Timespec::now(AllowMockedTime) for the deadline.

Fix (as originally proposed here; now on main via #37946)

  • Add Tag::DNSResolver to the opt-out list and read the clock with ForceRealTime in Resolver::add_timer, because the real heap is drained against the real clock, so a mocked deadline there would be either already due or years away. Same treatment DnsSdConnection and GcRepeating already had; bun:test: cover Bun.serve's Date header timer under fake timers #37935 does the same for the Date header timer.

[stamp-90s] gate passed · iteration 0 · 2 files touched

passes on PR (with fix)
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/js/bun/test/test-timers.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/bun/test/test-timers.test.ts
bun test v1.4.0 (81a19e17f)

test/js/bun/test/test-timers.test.ts:
(pass) we can go back in time [323.10ms]
(pass) advanceTimersByTime ticks from the setSystemTime value [9.40ms]
(pass) setSystemTime accepts pre-epoch and epoch times and resets with no argument [7.77ms]
(pass) useFakeTimers does not crash when globalThis.setTimeout is 'x' [454.05ms]
(pass) useFakeTimers does not crash when globalThis.setTimeout is Symbol() [379.06ms]
(pass) useFakeTimers does not crash when globalThis.setTimeout is 1n [364.99ms]
(pass) real timer heap is ticked against the real clock under useFakeTimers [3532.05ms]
(pass) c-ares poll timer under useFakeTimers > an in-flight dns.resolve*() query is not counted, and survives useRealTimers() [1485.01ms]
(pass) c-ares poll timer under useFakeTimers > runAllTimers() returns, and the query still times out on the real clock [4878.72ms]

 9 pass
 0 fail
 32 expect() calls
Ran 9 tests across 1 file. [16.84s]
Exit: 0
diff hotspot
.../bun/test/test-timers-dns-resolver-fixture.ts   | 68 +++++++++++++++++++++
 test/js/bun/test/test-timers.test.ts               | 70 ++++++++++++++++++++++
 2 files changed, 138 insertions(+)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                  reads  edits  tests
test/js/bun/test/test-timers-dns-resolver-fixture.ts      0      4      0
test/js/bun/test/test-timers.test.ts                      4      7      0

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3da9ed03-5bf8-4df5-ba78-2699d2d354d8

📥 Commits

Reviewing files that changed from the base of the PR and between 165dc9f and 90badff.

📒 Files selected for processing (4)
  • src/event_loop/EventLoopTimer.rs
  • src/runtime/dns_jsc/dns.rs
  • test/js/bun/test/test-timers-dns-resolver-fixture.ts
  • test/js/bun/test/test-timers.test.ts

Walkthrough

DNS resolver polling timers now use real-time behavior when fake timers are active. Tests cover cancellation, timer counts, real-clock timeouts, and child-process behavior.

Changes

DNS resolver timer behavior

Layer / File(s) Summary
Force DNS resolver timers to real time
src/event_loop/EventLoopTimer.rs, src/runtime/dns_jsc/dns.rs
Tag::DNSResolver is excluded from fake timers. Resolver timers use forced real-time deadlines when no timestamp is provided.
Validate DNS polling and timeout behavior
test/js/bun/test/test-timers-dns-resolver-fixture.ts, test/js/bun/test/test-timers.test.ts
Tests verify DNS query cancellation, fake-timer counts, real-clock ETIMEOUT results, and child-process termination.

Possibly related PRs

  • oven-sh/bun#36619: Both changes update DNS resolver timer handling in EventLoopTimer.rs and dns.rs.

Suggested reviewers: cirospaciari, 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 and concisely identifies the added c-ares poll timer test under fake timers.
Description check ✅ Passed The description explains the problem, changes, test coverage, and verification, although it uses equivalent headings instead of the template headings.

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

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:05 AM PT - Aug 13th, 2026

@robobun, your commit 81a19e17fba882443549d64261f2ff6f66007010 passed in Build #93867! 🎉


🧪   To try this PR locally:

bunx bun-pr 37940

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

bun-37940 --bun

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status: re-scoped to tests only, ready for review (head 81a19e1, based on current main).

#37946 merged while this was open and contains the two source lines this PR originally carried, so the source change is gone from here. What remains is the regression coverage for the DNS resolver, which #37946 did not add: getTimerCount() stays 0 with a query in flight and the query still times out after useRealTimers(); runAllTimers() returns while a query is in flight; the poll timer fires in real time even after the mocked clock has been pushed years ahead.

Verification: both tests pass on main at b7a777e with no source changes (debug build), and both fail on the released 1.4.0 binary (getTimerCount() is 1; the child spins in runAllTimers() until the spawn timeout kills it). They also failed the same way on the debug build of the pre-#37946 tree while the original fix was being developed. Because the fix is already on main, there is no unfixed main build for these to fail against; the released binary is the before state.

The earlier source-fix history (three bot reviews with no findings, 179 green CI jobs including x64 ASAN and darwin 14 on the last source head) is in the PR body's details block. #37935 (Date header timer) is the remaining sibling and still applies to test-timers.test.ts next to this; whichever lands second needs a trivial rebase of the test file.

@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 — a two-line source change following the existing DnsSdConnection/GcRepeating pattern for internal pacing timers, with hermetic tests covering both halves of the fix.

What was reviewed:

  • allow_fake_timers() opt-out addition matches the sibling DnsSdConnection entry; All::insert/remove route on in_heap, so removal from the real heap under fake timers is unaffected.
  • ForceRealTime in add_timer: the Some(now) path from check_timeouts already receives real time from the heap drain, so only the None path (from request_sent/request_completed) changes.
  • Tests are hermetic (local 127.0.0.1:0 UDP socket, no external DNS), drain child pipes concurrently, and the fixture's second case specifically covers the ForceRealTime half by pushing the mocked clock past uptime.
Extended reasoning...

Overview

Two source lines changed: Tag::DNSResolver added to the allow_fake_timers() opt-out list in src/event_loop/EventLoopTimer.rs, and Resolver::add_timer in src/runtime/dns_jsc/dns.rs switched from AllowMockedTime to ForceRealTime when computing the poll timer's deadline. A new spawned fixture and two tests are added to test/js/bun/test/test-timers.test.ts.

Security risks

None. No user-facing API change, no untrusted input handling, no crypto/auth. The change only affects which intrusive heap an internal 1s bookkeeping timer is placed in and which clock it reads.

Level of scrutiny

Low-to-medium. The change follows an established pattern precisely — DnsSdConnection (also DNS-related internal pacing) is already on the same opt-out list, and the PR description correctly explains why a real-heap timer must read the real clock (the real heap is drained against CLOCK_MONOTONIC, so a mocked-clock deadline would be either always-due or never-due). I traced All::insert (routes on fake_timers.is_active() && tag.allow_fake_timers()) and All::remove (routes on the node's in_heap field, not the tag), so a DNSResolver timer inserted into the real heap while fake timers are active is still removed correctly by remove_timer. I also confirmed check_timeouts passes the heap-drain's real now into add_timer(Some(&now)), so the re-arm path was already correct — only the initial arm from request_sent/request_completed was reading the mocked clock.

Other factors

Test quality is good: hermetic (local dgram socket on 127.0.0.1:0, never answered), the in-process test asserts ECANCELLED to prove the query was still pending when getTimerCount() was sampled, the child fixture is bounded by a spawn timeout below the test timeout so a pre-fix spin fails cleanly, and the fixture's second case (advance mocked clock years ahead, expect ETIMEOUT on real time) specifically exercises the ForceRealTime change — the PR notes it fails with the tag change alone. Pipes are drained concurrently. No behavior is being removed that users could have depended on: the poll timer only exists to let c-ares notice its own real-clock timeouts, so fake-timer enrollment never did anything but the observed spin/hang.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

One follow-up push coming for the tests only (src unchanged): the in-process test will wait for ETIMEOUT after useRealTimers() instead of cancelling the query. On the unfixed binary, useRealTimers() drops the poll timer along with the rest of the fake heap and a query started under fake timers stays pending forever, so that variant is worth covering too (verified: released binary hits the test timeout, this branch gets ETIMEOUT in about a second). The fixture is unchanged.

Comment thread src/runtime/dns_jsc/dns.rs Outdated
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: #37946 turns allow_fake_timers() into an allowlist (setTimeout/setInterval, AbortSignal.timeout, Bun.cron) and switches every other owner to ForceRealTime, which includes the DNSResolver tag and add_timer clock changes made here. The tests in this PR still apply on top of it; whichever of the two lands second needs a small rebase of allow_fake_timers().

@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 — mechanical opt-out following the established DnsSdConnection/GcRepeating pattern.

What was reviewed:

  • Tag::DNSResolver added to the allow_fake_timers() opt-out list; add_timer switched to ForceRealTime — both mirror dns_sd.rs exactly (same comment wording).
  • Verified check_timeouts re-arm already passes the real-clock now from the heap drain, so only the None callers (request_sent/request_completed) needed the clock-mode change.
  • Tests use a local non-answering UDP socket (no external network), port: 0, drain pipes concurrently, assert signalCode === null; the fixture's second case proves the ForceRealTime change is load-bearing independent of the tag change.
Extended reasoning...

Overview

Two source lines: (1) add Tag::DNSResolver to the allow_fake_timers() => false match arm in src/event_loop/EventLoopTimer.rs, alongside the existing DnsSdConnection, GcRepeating, and QuicEndpoint internal-pacing opt-outs; (2) switch Resolver::add_timer's fallback clock read from AllowMockedTime to ForceRealTime in src/runtime/dns_jsc/dns.rs. Plus a new fixture and a describe block appended to test-timers.test.ts.

The mechanism is well-explained and I verified it against the code: All::insert at src/runtime/timer/mod.rs:679 routes to the fake heap only when tag.allow_fake_timers(), so adding DNSResolver to the opt-out list keeps the poll timer in the real heap. The real heap drains against the real clock, so the deadline must be computed from the real clock too — hence ForceRealTime. The check_timeouts re-arm path (dns.rs:4073) already receives now from the real-heap drain and passes it as Some(&now), so only the None callers needed the change. This is the identical shape and identical one-line comment as dns_sd.rs:116-118 for DnsSdConnection.

Security risks

None. No user input touched; this is internal timer-heap routing for a bookkeeping poll that calls ares_process_fd(BAD, BAD).

Level of scrutiny

Low-to-medium. The source change is two lines following an existing sibling pattern verbatim. The blast radius is limited to how the c-ares poll timer behaves under jest.useFakeTimers() — outside fake timers, ForceRealTime and AllowMockedTime return the same value and the heap routing is identical, so there is no behavior change for normal DNS resolution.

Other factors

Test coverage is strong and follows repo conventions: local dgram socket on 127.0.0.1:0 that never answers (hermetic, no external network), bunExe()/bunEnv, await using proc, concurrent pipe drain, signalCode === null asserted before exitCode. The fixture mirrors the adjacent test-timers-gc-spin-fixture.ts (same advance-mocked-clock-past-uptime trick). The 30s test timeout is justified because the pre-fix behavior spins until the 20s spawn timeout, and the passing case waits ~1s of real time for c-ares to give up. The fixture's second test isolates the ForceRealTime change (would fail with only the tag change), so both source lines are proven load-bearing. The PR body reports both new tests fail on the unfixed build and released binary, and the related DNS/fake-timers suites still pass.

The comment-cop bot flag was addressed (comment trimmed to one line matching dns_sd.rs, thread resolved). No outstanding human review comments.

Jarred-Sumner pushed a commit that referenced this pull request Aug 13, 2026
…37946)

### 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: #37935 (Date header timer) and #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.

<details>
<summary>Repro on the released binary (1.4.0)</summary>

```ts
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.

</details>

<!-- robobun:evidence:begin -->

---

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

<details><summary>fails on main (without fix)</summary>

```console
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 (ada0dd1)

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 (da3851e)

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)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
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 (ada0dd1)

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)
```

</details>

<details><summary>diff hotspot</summary>

```
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(-)
```

</details>

**gate history** · 1 passed · 1 rejected · iteration 1

<details><summary>evidence per changed file</summary>

```
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
```

</details>

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
#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.
@robobun
robobun force-pushed the farm/e18019da/dns-resolver-timer-fake-timers branch from f45c3a2 to 81a19e1 Compare August 13, 2026 01:57
@robobun robobun changed the title bun:test: keep the c-ares poll timer out of the fake timer heap test: cover the c-ares poll timer under jest.useFakeTimers() Aug 13, 2026

@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 — test-only addition covering the c-ares poll timer under jest.useFakeTimers(); the source fix already landed via #37946.

What was reviewed:

  • Confirmed the diff is now test-only (source changes in EventLoopTimer.rs/dns.rs were subsumed by #37946, which is on main).
  • Tests are hermetic (local 127.0.0.1 UDP socket, no external DNS), clean up via try/finally, and drain child stdout/stderr/exited concurrently.
  • Fixture follows the existing test-timers-gc-spin-fixture.ts pattern in the same file; non-.test.ts name keeps it out of the default glob.
  • Checked the second fixture test guards the ForceRealTime clock read specifically (mocked clock pushed years ahead), not just the tag opt-out.
Extended reasoning...

Overview

This PR now adds only test coverage: a new describe block in test/js/bun/test/test-timers.test.ts and a spawned fixture test-timers-dns-resolver-fixture.ts. The two source-line fix it originally carried (adding Tag::DNSResolver to the fake-timer opt-out and switching Resolver::add_timer to ForceRealTime) has already merged as part of #37946, which converted allow_fake_timers() into an allowlist. I verified on the checked-out head that allow_fake_timers() is the allowlist form (TimeoutObject | AbortSignalTimeout | CronJob) and that add_timer reads ForceRealTime, so these tests exercise code that is already on main.

Security risks

None. The tests bind a UDP socket on 127.0.0.1:0 and point a dns.promises.Resolver at it; nothing answers, and no external network is contacted. No production code is touched.

Level of scrutiny

Low-to-medium. Test-only, follows the exact shape of the neighboring real timer heap is ticked against the real clock test (same spawn timeout / signalCode assertion / stdout JSON-line pattern). The in-process test correctly scopes useFakeTimers()/useRealTimers() in a try/finally and closes the UDP socket in an outer finally registered before the assertions. The child-process test uses await using on the spawn handle, drains all three streams with Promise.all, and asserts signalCode === null before exitCode === 0 so a spinning child produces a clear failure.

Other factors

  • The fixture's first test keeps the query alive ~50 s (5 s × 10 tries, citing c-ares' MAX_TIMEOUT_MS) so a regression to the pre-fix spin is caught by the 20 s spawn timeout rather than accidentally passing when c-ares gives up early.
  • The fixture's second test advances the mocked clock past any plausible uptime and then awaits a real ETIMEOUT, which specifically guards the ForceRealTime read in add_timer — a regression on that line alone would leave the deadline years out and hang this test.
  • The 30 s per-test timeout is higher than the repo guideline but is justified in-line: the child needs a 20 s bound to catch the pre-fix spin, and the passing path spends ~1 s of real time.
  • The comment-cop bot's note on dns.rs was addressed in an earlier push and is moot now that the source diff is gone.
  • CI on the earlier head (which included the source changes) was green on all lanes that ran; the current head only differs in tests.

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.

1 participant