Skip to content

node:http: measure the keep-alive idle period on the monotonic clock - #37974

Open
robobun wants to merge 5 commits into
mainfrom
farm/91f73f53/http-keepalive-idle-monotonic-clock
Open

node:http: measure the keep-alive idle period on the monotonic clock#37974
robobun wants to merge 5 commits into
mainfrom
farm/91f73f53/http-keepalive-idle-monotonic-clock

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A node:http server's keep-alive idle expiry follows Date.now(), so bun:test's setSystemTime() and useFakeTimers() (which overrides Date.now as well) decide when, or whether, an idle kept-alive connection is closed. Reproduced on 1.4.0 with keepAliveTimeout = 500, keepAliveTimeoutBuffer = 0: clock moved back an hour after the response, the connection is still open when the test times out; clock pinned before the request (the usual setSystemTime(new Date("2020-01-01"))), it closes after 1000ms instead of 500; clock moved ahead an hour after a late second response, it closes ~200ms after that response instead of 500; useFakeTimers() called after the response, it is never closed.
  • Cause: src/js/node/_http_server.ts. onResponseFinishHandleSocket leaves the socket timer armed across responses and only records socket[kKeepAliveIdleStart] = DateNow(); when the timer fires, onSocketTimeoutTimerExpired computes remaining = socket.timeout - (DateNow() - idleStart) and re-arms for remaining when it is positive. Clock moved back: remaining is the budget plus the jump. Pinned: the full budget once more. Moved ahead: negative, so a fire that predates the idle period closes the connection. Fake timers: the re-arm's setTimeout lands in the fake heap, where nothing fires it.
  • Capturing Date.now at module load does not help: setSystemTime() sets JSGlobalObject::overridenDateNow, which Date.now() consults in every tier. useFakeTimers() also freezes performance.now(), process.hrtime() and Bun.nanoseconds(), so builtin JS had no clock to measure this with.

Fix

  • src/runtime/timer/Timer.rs: internal_bindings.monotonicNowMs(), returning Timespec::now(ForceRealTime).ms(), next to the existing timerClockMs binding (which reads the mockable clock on purpose, for ported Node timer tests). src/js/internal/timers.ts exports it as monotonicNowMs, with the rationale on the export.
  • _http_server.ts takes the idle mark and the fire-time reading with it, and takes the mark before arming the timer. Both readings are whole milliseconds, so a timer armed after the mark always measures at least the full budget when it fires; before, a mark taken just after arming could round the other way and re-arm for one more millisecond (harmless on real timers, a stranded connection if fake timers were enabled in between). Per-response cost is still one native clock read.
  • Why ForceRealTime rather than the timer subsystem's AllowMockedTime clock: keepAliveTimeout is a real-time budget, and the socket timer it is reconciled against is a real timer whenever it was armed before any mocking started, which is the case for every connection a test suite's earlier tests leave behind. ForceRealTime is the clock that real heap is drained against and nothing in bun:test can move it. AllowMockedTime would read as the fake clock (which starts at zero when useFakeTimers() is called) against a mark taken on the real one, and re-arm the connection for roughly the machine's uptime; the fourth test below fails with exactly that variant (verified by building it). The same property covers production: the wall clock can be stepped by NTP, the monotonic clock cannot. It also matches Node, whose keep-alive expiry runs on libuv's loop time, which neither Date mocking nor jest's fake timers touch.
  • Not changed: under useFakeTimers() enabled before the response, the socket timer itself is a plain setTimeout and still lands in the fake heap (builtin JS has no real-time timer yet; bun:test: keep runtime-internal timeouts out of the fake timer heap #37946 did this for native timers). With a real-clock mark, a fake fire that comes before the budget has really elapsed re-arms once for the remainder, so such a connection may take two advances instead of one; nothing in the tree depends on either.
  • Overlap: sql: measure the connect-retry budget on the monotonic clock #37960 adds the same Rust binding for the SQL connect-retry budget and binds it from sql/shared.ts directly, so the internal/timers export is only added here. The binding code is identical in both; only its doc comment differs (each points at its own consumer), so the conflict for whichever lands second is that comment and nothing else. This branch merges cleanly into current main.
  • Tests: four serial cases in test/js/node/http/node-http-server-timeouts.test.ts, each measuring the idle time from just before the server's own res.end() to the client's close, so the number can only over-report the server's measurement. Clock moved ahead after a second response answered 300ms late: unfixed ~140-200ms against a >= 350 bound, fixed ~500. Clock pinned before the request: unfixed 1001-1004ms against a < 1000 bound (the unfixed server re-arms for the full budget, so two budgets is a floor independent of machine speed), fixed ~500-530, and the >= 350 bound as well. Clock moved back, and fake timers enabled after the response: unfixed never closes (test timeout), fixed ~500. Each probe resets the mocks before it starts and after it ends, so a timed-out probe cannot leak into the next one. All four fail on 1.4.0 individually and together; the file passes repeatedly on the debug build.
  • Also passing on the debug build: the rest of that file, node-http.test.ts, node-http-res-settimeout-unref.test.ts, and the ported test-http-keep-alive-timeout*, test-http-server-keep-alive-*, test-http-server-*-timeout-*, test-http-set-timeout*.js suites (17 files). cargo clippy -p bun_runtime, rustfmt, oxlint and prettier are clean.

Background

  • Keep-alive idle bookkeeping (node:http: server hot-path performance (stacked on #32488) #33879): rescheduling the socket timer on every response was the largest per-request cost of the keep-alive path, so after the first response the timer is left armed and each response only records when its idle period started. A fire that comes before idleStart + budget re-arms once for the remainder; the next fire closes the connection.
  • setSystemTime(d) pins Date.now() at d (it does not advance) until reset; it touches no other clock. useFakeTimers() pins Date.now(), performance.now(), process.hrtime() and Bun.nanoseconds(), installs a mocked monotonic clock starting at zero that only jest.advanceTimersByTime() and friends move, and diverts new setTimeouts into a heap that only those calls drain; timers armed before the call stay in the real heap and keep firing.
  • Timespec::now(mode) is bun's monotonic clock (CLOCK_MONOTONIC, QPC on Windows). AllowMockedTime returns the mocked clock while fake timers are installed; ForceRealTime never does. The event loop drains the real timer heap against ForceRealTime.
  • $newRustFunction(file, symbol, argc) is how builtin JS calls into Rust: codegen emits a thunk per call site that calls the named function directly, so a misnamed Rust function is a compile error.
Measured close times (ms after the last response)
clock moved ahead, second response answered late   bound: >= 350
  unfixed debug 138-169, unfixed release 199-201, fixed debug ~500-620
clock pinned before the request                    bounds: >= 350 and < 1000
  unfixed release 1001-1004, fixed debug ~500-530
clock moved back after the response                bound: >= 350
  unfixed: never closes (5s test timeout), fixed debug ~500-530
useFakeTimers() after the response                 bound: >= 350
  unfixed: never closes (5s test timeout), fixed debug ~500-530
  binding switched to AllowMockedTime: never closes (the other three cases pass)
Earlier revision of this PR

The first revision took the mark after arming the timer and shipped only the moved-ahead and moved-back cases. Review added the pinned and fake-timers cases, which is what surfaced the rounding re-arm and the ForceRealTime argument above.


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

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/http/node-http-server-timeouts.test.ts
error: bindgenv2 emitted unexpected output type: /workspace/bun/build/debug/codegen/GeneratedSocketConfigBinaryType.h, /workspace/bun/build/debug/codegen/GeneratedSocketConfigHandlers.h, /workspace/bun/build/debug/codegen/GeneratedSocketConfig.h, /workspace/bun/build/debug/codegen/GeneratedSocketConfigTLS.h, /workspace/bun/build/debug/codegen/GeneratedALPNProtocols.h, /workspace/bun/build/debug/codegen/GeneratedSSLConfig.h, /workspace/bun/build/debug/codegen/GeneratedSSLConfigFile.h, /workspace/bun/build/debug/codegen/GeneratedSSLConfigSingleFile.h, /workspace/bun/build/debug/codegen/GeneratedFakeTimersConfig.h
error: script "bd" exited with code 1
__F:-1:S:0

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

test/js/node/http/node-http-server-timeouts.test.ts:
(pass) node:http server timeout enforcement > headersTimeout closes a connection that never completes its request headers [257.34ms]
(pass) node:http server timeout enforcement > requestTimeout closes a connection that stalls mid-body [355.14ms]
(pass) node:http server timeout enforcement > server.setTimeout() fires the 'timeout' event for an inactive connection [205.53ms]
(pass) node:http server timeout enforcement > keepAliveTimeout closes an idle keep-alive connection after the response [1206.05ms]
(pass) node:http server timeout enforcement > headersTimeout answers 408 when there is no 'clientError' listener [254.62ms]
(pass) node:http server timeout enforcement > requestTimeout does not fire while a slow handler streams a response [406.75ms]
315 |     // period sees an hour of idle time and closes the connection right there.
316 |     const idleMs = await probeIdleClose({
317 |       requests: 2,
318 |       afterLastResponse: () => setSystemTime(new Date(Date.now() + HOUR_MS)),
319 |     });
320 |     expect(idleMs).toBeGreaterThanOrEqual(KEEP_ALIVE_MS - 150);
          
... (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/node/http/node-http-server-timeouts.test.ts
bun test v1.4.0 (fb9cfb248)

test/js/node/http/node-http-server-timeouts.test.ts:
(pass) node:http server timeout enforcement > headersTimeout closes a connection that never completes its request headers [959.68ms]
(pass) node:http server timeout enforcement > requestTimeout closes a connection that stalls mid-body [506.96ms]
(pass) node:http server timeout enforcement > server.setTimeout() fires the 'timeout' event for an inactive connection [344.66ms]
(pass) node:http server timeout enforcement > keepAliveTimeout closes an idle keep-alive connection after the response [1430.95ms]
(pass) node:http server timeout enforcement > headersTimeout answers 408 when there is no 'clientError' listener [350.03ms]
(pass) node:http server timeout enforcement > requestTimeout does not fire while a slow handler streams a response [623.50ms]
(pass) keepAliveTimeout idle expiry ignores mocked clocks > a clock moved forwards does not close the connection before its idle budget is used up [984.55ms]
(p
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1059ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/45] gen bindgenv2
[2/40] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[3/40] gen cpp.rs (cppbind)
[4/40] gen JS modules (bundle-modules)
Preprocess modules (15644ms)
Bundle modules (213ms)
Postprocesss modules (590ms)
Bundle Functions (1451ms)
Generate Code (54ms)

[17.98s] Bundled "src/js" for production
  2626 kb
  197 internal modules
  13 native modules
  91 internal functions across 17 files
[4/29] 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_paths v0.0.0 (/workspace/bun/src/paths)
�[1m�[92m   Compiling�[0m bun_sys v0.0.0 (/workspace/bun/src/sys)
�[1m�[92m   Compiling�[0m bun_url v0.0.0 (/workspace/bun/src/url)
�[1m�[92m   Compiling�[0m bun_http_types v0.0.0 (/workspace/bun/src/http_types)
�[1m�[92m   Compiling�[0m bun_threading v0.0.0 (/workspace/bun/src
... (truncated)
diff hotspot
src/js/internal/timers.ts                          |  11 ++
 src/js/node/_http_server.ts                        |  14 +-
 src/runtime/timer/Timer.rs                         |  12 ++
 .../js/node/http/node-http-server-timeouts.test.ts | 141 ++++++++++++++++++++-
 4 files changed, 172 insertions(+), 6 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                 reads  edits  tests
src/js/internal/timers.ts                                3      4      0
src/js/node/_http_server.ts                             12      8      0
src/runtime/timer/Timer.rs                               3      4      0
test/js/node/http/node-http-server-timeouts.test.ts      8     13      0

root cause · written by the author bot

The node:http server measured keep-alive idle time with Date.now(), both when recording the idle start after each response and when the timer fired to compute the remaining budget, so bun:test's setSystemTime() or fake timers could delay, advance, or double the idle expiry of a kept-alive socket. The fix adds a monotonicNowMs binding backed by the real-time timer clock, exported from internal/timers, and uses it at both sites so the idle measurement is immune to wall-clock mocking. The idle start is also recorded before the timer is armed, so a timer that fires measures at least the full bu…

The server records when a connection's last response finished and, when
the socket timer fires, grants whatever is left of the keep-alive budget
from that mark. Both readings came from Date.now(), which bun:test's
setSystemTime() (and fake timers) override inside the engine: a clock
moved back re-armed the idle connection for the budget plus the jump, a
pinned clock doubled the budget, and a clock moved ahead closed the
connection as soon as the timer fired.

Add internal/timers.monotonicNowMs(), a Rust binding returning the real
monotonic clock in ms, and take both readings with it.
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix pushed (fb9cfb2); CI is green apart from one lane that fails on main as well.

  • Reproduced on 1.4.0 with the four cases added to test/js/node/http/node-http-server-timeouts.test.ts (keepAliveTimeout = 500): clock moved an hour ahead after a late second response, the connection closes ~200ms after it; clock pinned before the request, it closes after ~1000ms; clock moved back, or useFakeTimers() enabled after the response, it never closes. All four pass on this branch (debug build, repeated runs) and fail on the unfixed binary; the file passes on every CI lane.
  • The only red lane on the latest build is the alpine x64 shard carrying test/cli/run/require-cache.test.ts, which is failing on main independently of this change (reported separately). The other failures listed on the build passed on retry.
  • Shares the monotonicNowMs binding with sql: measure the connect-retry budget on the monotonic clock #37960 (SQL retry budget); identical code, doc comment differs, comment-only conflict for whichever lands second.

@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The change adds a monotonic millisecond timer binding. HTTP keep-alive timing uses this binding instead of Date.now(). Tests verify timeout behavior when system time moves forward or backward.

Changes

Monotonic keep-alive timing

Layer / File(s) Summary
Monotonic timer binding
src/runtime/timer/Timer.rs, src/js/internal/timers.ts
Adds the monotonic_now_ms host function and exposes monotonicNowMs through the internal timers module.
Keep-alive clock usage
src/js/node/_http_server.ts
Uses monotonicNowMs() to record idle periods and calculate remaining keep-alive timeouts.
Keep-alive clock regression tests
test/js/node/http/node-http-server-timeouts.test.ts
Tests keep-alive behavior across forward and backward system-clock changes and restores test resources during cleanup.

Possibly related PRs

  • oven-sh/bun#37946: Both changes update timer handling to use real monotonic or system time.

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 describes the main change: using the monotonic clock for node:http keep-alive idle measurement.
Description check ✅ Passed The description explains the problem, fix, design rationale, regression tests, and verification results in sufficient detail.

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

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

🤖 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/node/http/node-http-server-timeouts.test.ts`:
- Around line 258-260: Update the socket handling around the
Promise.withResolvers call so the error listener rejects closed with the emitted
socket error instead of swallowing it. Keep the close listener resolving on
normal closure, ensuring every socket failure event causes the probe to fail.
🪄 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: a4c4258f-7a03-4c9d-9e3a-cc96bca2305d

📥 Commits

Reviewing files that changed from the base of the PR and between 8a1cd8d and a6b957a.

📒 Files selected for processing (4)
  • src/js/internal/timers.ts
  • src/js/node/_http_server.ts
  • src/runtime/timer/Timer.rs
  • test/js/node/http/node-http-server-timeouts.test.ts

Comment thread test/js/node/http/node-http-server-timeouts.test.ts Outdated

@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-site clock swap with a matching Rust binding, following the existing timerClockMs pattern.

What was reviewed

  • Both keep-alive idle readings (write at onResponseFinishHandleSocket, read at onSocketTimeoutTimerExpired) now use the same monotonicNowMs() clock, so the delta stays well-defined; ForceRealTime matches the heap the socket timer drains against.
  • Confirmed the removed DateNow local has no remaining callers in _http_server.ts, and keepAliveTimeoutBuffer: 0 is a valid createServer option.
  • Tests: lower-bound-only assertions (no upper bound to flake under ASAN), setSystemTime() reset in finally, kept serial because the mock is process-wide — the forward-skew bound (≥350ms) cleanly separates the unfixed ~200ms close.
Extended reasoning...

Overview

Replaces Date.now() with a new monotonicNowMs() binding at the two sites in src/js/node/_http_server.ts that measure the keep-alive idle interval (kKeepAliveIdleStart write on response finish, and the remaining computation when the socket timer fires). Adds the binding in src/runtime/timer/Timer.rs (Timespec::now(ForceRealTime).ms()) alongside the existing timerClockMs, exports it from src/js/internal/timers.ts, and removes the now-dead DateNow capture. Two regression tests added to test/js/node/http/node-http-server-timeouts.test.ts.

Security risks

None. This changes only which clock a subtraction reads; no new inputs, no parsing, no auth/crypto surface.

Level of scrutiny

Low-to-medium. The runtime change is a one-for-one substitution of the clock source at two call sites that already agreed with each other; both now read monotonicNowMs(), so the delta is still coherent. The Rust binding is a four-line clone of the neighbouring timer_clock_ms with ForceRealTime instead of AllowMockedTime, wired via the same $newRustFunction("runtime/timer/Timer.rs", "internal_bindings.*", 0) pattern already used for timerClockMs in internal-for-testing.ts. The PR description explains why ForceRealTime is the right mode (it is the clock the real timer heap is drained against, and setSystemTime() leaves the socket timer on that heap), and that this matches Node's libuv-loop-time behaviour.

Other factors

  • Verified via grep that DateNow has no remaining references in _http_server.ts after removal.
  • The new tests assert lower bounds only (>= KEEP_ALIVE_MS - 150), so debug/ASAN slowness cannot cause false failures; the forward-skew case discriminates cleanly (unfixed ~140-200ms vs bound 350ms) and the backward-skew case times out unfixed. setSystemTime() is reset in finally, and the tests are deliberately serial with a comment explaining the process-wide state.
  • The PR notes the Timer.rs/internal/timers.ts hunks are byte-identical to #37960, so merge order is irrelevant.
  • Author reports the full keep-alive/timeout ported-test suite (17 files) plus the rest of this file pass on the debug build, and clippy/oxlint are clean.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. sql: measure the connect-retry budget on the monotonic clock #37960 - Adds the byte-identical monotonic_now_ms host fn in src/runtime/timer/Timer.rs and the same monotonicNowMs export in src/js/internal/timers.ts for the same fake-timer/setSystemTime() motivation; only the consumer differs (SQL connect-retry budget vs. node:http keep-alive idle).

🤖 Generated with Claude Code

An idle keep-alive close is a clean FIN, so a reset or any other socket
error now fails the probe instead of being timed as a close. Also fix
the SLOW_RESPONSE_MS comment: the timer fires KEEP_ALIVE_MS minus
SLOW_RESPONSE_MS into the second idle period, not SLOW_RESPONSE_MS.
Comment thread src/js/internal/timers.ts
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/runtime/timer/Timer.rs Outdated
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:58 AM PT - Aug 13th, 2026

@robobun, your commit fb9cfb2 has 1 failures in Build #94349 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37974

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

bun-37974 --bun

Comment thread src/js/node/_http_server.ts
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #37960: that PR fixes the SQL connect-retry budget, this one fixes node:http's keep-alive idle expiry; they share only the new monotonicNowMs binding, and the two shared hunks are byte-identical by design so either merge order works. Review feedback so far: the probe now rejects on socket errors (733f24d) and the keep-alive comment is down to one sentence (e829af5); no changes to the fix itself.

@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-site Date.now()monotonicNowMs() swap for the keep-alive idle mark, plus the trivial Rust binding it calls.

What was reviewed:

  • Both readings of kKeepAliveIdleStart (write at response-finish, read at timer-fire) now use the same monotonic clock; the removed DateNow capture has no remaining uses in _http_server.ts.
  • monotonic_now_ms mirrors the sibling timer_clock_ms binding, differing only in ForceRealTime vs AllowMockedTime; $newRustFunction is the established codegen path.
  • New tests reset setSystemTime() in finally, reject on socket error, and stay serial because earlier tests in the file time themselves with Date.now().
Extended reasoning...

Overview

Four files. src/runtime/timer/Timer.rs adds a 6-line #[bun_jsc::host_fn] returning Timespec::now(ForceRealTime).ms(), placed directly beside the existing timer_clock_ms binding it copies. src/js/internal/timers.ts exports it via $newRustFunction. src/js/node/_http_server.ts replaces DateNow() with monotonicNowMs() at the only two sites that touch kKeepAliveIdleStart and drops the now-dead const DateNow = Date.now capture (grep confirms no other uses). Two regression tests are added to the existing keep-alive timeouts file.

Security risks

None. No user input reaches the new binding (it takes no arguments), and the _http_server.ts change is a clock-source swap with no control-flow change. Nothing auth-, crypto-, or permissions-related.

Level of scrutiny

Low-to-medium. The production change is mechanical: the two timestamps are only ever subtracted from one another, so as long as both come from the same monotonic source the arithmetic is unchanged. ForceRealTime is the correct choice — verified in bun_core/util.rs that it bypasses the mocked-time hook while AllowMockedTime consults it — and matches the clock the real timer heap is drained against, so the remaining computation now agrees with the timer that fires it. The new Rust host fn follows the exact shape of its neighbor.

Other factors

The bug-hunting pass found nothing. CodeRabbit's one concern (test probe swallowing socket errors) was addressed in 733f24d and the thread is resolved. The comment-cop bot's flags were about doc-comment length, not code, and are resolved. The tests wire error to reject, restore setSystemTime() in finally before any assertion can leak state, and use lower-bound-only assertions (>= 350ms) that the PR description shows fail on the unfixed build (~140-200ms for the forward-skew case; test timeout for the backward-skew case). No CODEOWNERS cover these paths. The shared Timer.rs/internal/timers.ts hunks are noted as byte-identical to #37960 so merge order doesn't matter.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up from #37960 (the SQL side): after a self-review pass it now binds monotonicNowMs directly in sql/shared.ts with $newRustFunction and no longer touches src/js/internal/timers.ts (for SQL the require("internal/timers") pulled validators / internal/shared / primordials into every Bun.SQL load; for _http_server.ts that module is already loaded through node:net, so the export here is fine as is). The Timer.rs hunk in #37960 is unchanged and still identical to yours. #37960 also adds timerInternals.monotonicNowMs to bun:internal-for-testing plus a fake-timers.test.ts case pinning ForceRealTime; if you add the same, copying those two hunks verbatim keeps the two PRs merge-clean in either order.

Both the mark and the clock read when the timer fires are whole
milliseconds, so a timer armed after the mark always measures at least
the full budget when it fires. Before, a mark taken just after arming
could round the other way and re-arm the connection for one more
millisecond; with fake timers enabled in the meantime that re-arm would
land in the fake heap and the connection would never be closed.

Also say once, on the internal/timers export, which clocks the mocks
touch, instead of repeating it on the binding.
The pinned case carries an upper bound: measured against a Date.now()
that never advances, the unfixed server re-arms for the whole budget
once more, so it closes no sooner than two budgets on any machine. The
fake-timers case is what pins the choice of clock: a mark taken on the
mocked monotonic clock (which starts over at zero) strands the
connection just like one taken on Date.now() does.

The idle time is now measured from just before each res.end() and read
after the mocks are undone, so it can only over-report the server's own
measurement, and every probe resets the mocks first so one that times
out cannot leak them into the next.
Comment thread src/js/internal/timers.ts
Comment thread src/js/node/_http_server.ts
Comment thread src/runtime/timer/Timer.rs
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed eaa9a9f and fb9cfb2 after a review pass over the first revision:

  • The idle mark is now taken before the socket timer is armed. Both readings are whole milliseconds, so a timer armed after the mark always measures the full budget when it fires; taken the other way round it could re-arm for one rounding millisecond, which is harmless on real timers but strands the connection if fake timers were enabled in between.
  • Two more cases: a clock pinned before the request (with a < 2 * budget upper bound, which the unfixed server misses by construction since it re-arms for the whole budget once more), and useFakeTimers() enabled after the response. The second one is what pins the choice of clock: building the binding with AllowMockedTime instead of ForceRealTime passes the three setSystemTime() cases and fails that one, because the mocked monotonic clock starts at zero and the fire re-arms the connection for roughly the machine's uptime. Details and numbers are in the PR body.
  • The probe now times from just before the server's own res.end() and reads the end only after the mocks are undone, and resets the mocks before starting, so a probe that times out cannot leak into the next one.
  • The two shared doc comments were corrected (setSystemTime() pins only Date.now(); no caller named in the Rust doc), so they are no longer byte-identical to sql: measure the connect-retry budget on the monotonic clock #37960; the code lines still are, and the text is posted there.

@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 — swaps the two Date.now() reads in the keep-alive idle bookkeeping for a real monotonic clock, which is the textbook fix for interval measurement.

What was reviewed:

  • monotonic_now_ms mirrors the adjacent timer_clock_ms binding exactly, differing only in ForceRealTime vs AllowMockedTime.
  • DateNow had no other callers in _http_server.ts; internal/timers is already loaded via node:net, so the new require is a cache hit.
  • Moving the kKeepAliveIdleStart mark before the timer arm is sound (avoids a 1ms rounding re-arm) and does not change the leave-timer-armed fast path.
  • Tests: probe rejects on socket error, mocks reset at both entry and finally, and the < 2×budget bound is safe because the unfixed floor is exactly two timer fires.

The three open comment-cop pings are the same doc-comment lines the author already justified in the earlier round (and the Timer.rs/internal/timers.ts ones are kept byte-identical to #37960's Rust hunk on purpose).

Extended reasoning...

Overview

The PR fixes a bug where node:http's server-side keep-alive idle expiry was measured with Date.now(), so bun:test's setSystemTime() / useFakeTimers() decided when (or whether) idle connections closed. The fix adds a monotonicNowMs binding (Timespec::now(ForceRealTime).ms()) in src/runtime/timer/Timer.rs, exports it from src/js/internal/timers.ts, and uses it at the two sites in src/js/node/_http_server.ts that record and read the idle-start mark. The DateNow = Date.now capture is deleted (no other callers). Four new tests in test/js/node/http/node-http-server-timeouts.test.ts cover clock-forward, clock-pinned, clock-back, and fake-timers-after-response.

Security risks

None. No user input is parsed; the change reads a monotonic clock instead of the wall clock in an internal timer-bookkeeping path. No auth, crypto, path, or network-parsing surface is touched.

Level of scrutiny

Moderate. The keep-alive path in _http_server.ts is hot (the whole leave-the-timer-armed design from #33879 exists to keep it cheap), but the runtime change is two clock-read call sites plus moving one assignment above the timer arm. The Rust binding is 8 lines that mirror the existing timer_clock_ms immediately above it, differing only in ForceRealTime. $newRustFunction codegen resolves internal_bindings.monotonicNowMsmonotonic_now_ms the same way it does for timerClockMs, so a mismatch would be a build error. The per-response cost stays one native clock read, and internal/timers is already loaded transitively via node:net, so the top-level require adds no module-load work.

The mark reorder (take kKeepAliveIdleStart before arming the timer) is a small, well-explained refinement: both readings are whole milliseconds, so a timer armed after the mark provably measures ≥ total on fire and closes instead of re-arming for a rounding millisecond. In the leave-armed fast path no timer is armed at all, so ordering is irrelevant there.

Other factors

  • Tests await the socket close event (no sleeps), reject on error (fixed after CodeRabbit's note in 733f24d), reset mocks at probe entry and in finally so a timed-out probe cannot poison later tests, and are kept serial because the earlier tests in the file time themselves with Date.now(). The one upper-bound assertion (< 2×KEEP_ALIVE_MS for the pinned-clock case) is justified: the unfixed build always takes at least two full timer intervals to close, independent of machine speed, so the fixed build only has to beat that floor.
  • Cross-PR: #37960 now binds monotonicNowMs directly in sql/shared.ts and no longer edits internal/timers.ts, but its Timer.rs hunk is still identical to this one, so either merge order stays clean. The internal/timers export here is appropriate for _http_server.ts since that module is already in the node:net load graph.
  • The three unresolved github-actions comment-cop pings are the automated length heuristic re-firing on the latest push; the author already responded to the identical earlier round explaining these are doc comments on a new export (not workaround justifications) and are kept verbatim to stay merge-clean with #37960. I agree — the monotonicNowMs JSDoc names exactly which clocks it must not be confused with, which is the non-obvious part a future reader would otherwise undo.

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