websocket: fix idleTimeout: 0 to actually disable the idle timeout - #34222
websocket: fix idleTimeout: 0 to actually disable the idle timeout#34222hughescr wants to merge 4 commits into
Conversation
WebSocketContextData::calculateIdleTimeoutComponents(0) computed `idleTimeout - margin` (0 - 4) on an unsigned short, underflowing to 65532. uSockets' tick wheel then treated that as a real ~252-second timeout instead of "disabled": every `idleTimeout: 0` websocket (including our own inspector/debugger connection in debugger.ts, which sets this to mean "never time out") got a ping at ~248s and, if unanswered, a force-close 4s later. us_socket_timeout(s, 0) already means "disabled" (sets a sentinel timeout slot the tick-wheel sweep never matches); the bug was purely that the idle-detection component never got to pass a literal 0 through. Special-case idleTimeout == 0 to skip the underflowing subtraction. The ping/force-close-after-end() component is left untouched, since it also serves as an unrelated post-close grace period that must keep working regardless of idleTimeout. Verified upstream uWebSockets has the identical unfixed bug, so there's no newer upstream special-case to mirror.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughChangesThe WebSocket idle-timeout calculation now preserves Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
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/websocket/websocket-server.test.ts`:
- Around line 1664-1665: Remove both explicit 30_000 timeout arguments from the
affected websocket server tests in
test/js/bun/websocket/websocket-server.test.ts at lines 1664-1665 and 1698-1699,
leaving each test invocation to use the repository runner’s build-aware timeout
budget.
- Around line 1549-1567: Replace the current bounded-duration WebSocket timing
assertions with a regression test that directly exercises
calculateIdleTimeoutComponents(0), or expose a deterministic timer/test hook
that makes the underflow observable without waiting for the historical timeout.
Ensure the test fails against the underflowing implementation, passes with the
fix, and awaits the actual observable condition rather than relying on
setTimeout or a fixed 20-second window; apply the same correction to the related
test blocks.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 0f1fbb17-b784-4ab1-9663-f76d13fb71b2
📒 Files selected for processing (2)
packages/bun-uws/src/WebSocketContextData.htest/js/bun/websocket/websocket-server.test.ts
… internal-for-testing The two socket-level idleTimeout: 0 tests observe a 20-second window, but the pre-fix bug's real symptom is a ~252-second close (65532, the unsigned-short underflow of 0 - 4, rounded through uSockets' 240-slot, 4s-granularity tick wheel) -- so those tests pass on an unfixed build too and cannot, by construction, catch the underflow itself. Guard the underflow with a sub-millisecond unit test instead: a new testing-only TU (src/jsc/bindings/websocket_idle_timeout_testing.cpp, modeled on xxhash3_testing.cpp) instantiates the vendored WebSocketContextData template directly and exposes calculateIdleTimeoutComponents through bun:internal-for-testing (the xxHash3ForTesting pattern). The new test asserts (0, true) and (0, false) both yield [0, 4] -- idle-detection component 0, not 65532. Verified red on the unfixed arithmetic: temporarily reverting the header's special-case makes it fail reporting 65532. The socket-level tests are retained unchanged as end-to-end wiring coverage: they prove the computed components actually reach a real socket through Bun.serve -> uWS -> uSockets, and that the ping/close mechanism itself works.
* upstream/main: (52 commits) node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed (oven-sh#32488) expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266) lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253) Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289) test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297) worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278) buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273) fs.promises.watch: yield events with a null prototype (oven-sh#34279) child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268) Fix asString assertion when passing String objects as signals (oven-sh#34265) Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274) test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294) tty: track raw mode per handle instead of per process (oven-sh#33527) test: expect the bumped mimalloc SHA in process.versions Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181) Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131) test: update block-scoped enum lowering expectations to let (oven-sh#34287) Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259) js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246) js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245) ... # Conflicts: # test/js/bun/websocket/websocket-server.test.ts
* upstream/main: (422 commits) install: drop packages held only by optional-peer resolution slots from bun.lock (oven-sh#35681) Update mimalloc to the upstream dev3 (v3.4.3) sync (oven-sh#36431) compile(pe): ftruncate the Windows --compile output after writing (oven-sh#36430) Strong: back bun_jsc::Strong with StrongRootBlock; free AbortSignal.timeout at wrapper GC (oven-sh#35849) test(harness): replace toRun matcher with async bunRun + toSpawn (oven-sh#36424) test: measure memory via harness rss() instead of process.memoryUsage.rss() (oven-sh#36429) Deflake a few tests no-orphans(windows): allow CREATE_BREAKAWAY_FROM_JOB and set DIE_ON_UNHANDLED_EXCEPTION on the Job (oven-sh#36414) GarbageCollectionController: replace per-tick heap sampler with idle timer only (oven-sh#35356) exe_format(pe): write a valid OptionalHeader.CheckSum for --compile output (oven-sh#36383) FileSink: flush buffered bytes when process.exit() runs in the same tick as write() (oven-sh#36250) test(http): speed up and de-flake serve-async-stream-client-abort.test.ts (oven-sh#35919) test(20144): stop racing child startup against the 1s SIGKILL guard (oven-sh#34166) test(no-orphans): skip fast-exit perl daemon test on macOS (oven-sh#36413) fs: return negative BigIntStats *Ns for pre-epoch timestamps (oven-sh#36187) event_loop: make DeferredTaskQueue::run tolerate re-entrant map mutation (oven-sh#32703) dotenv: stop panicking on nested `${...}` inside `${VAR:-default}` (oven-sh#36199) fetch: make the idle timer an absolute deadline for the response header block (oven-sh#36145) bundler: don't panic on unterminated naming template placeholders (oven-sh#36325) Buffer#indexOf/lastIndexOf: rare-byte SIMD filter with a Two-Way O(n+m) fallback (oven-sh#36420) ...
|
Housekeeping for reviewers: I've just merged latest Since I have four PRs open (#34219, #34220, #34221, #34222), a quick map so nobody has to reconstruct it: this one shares nothing with the other three — no source or test file overlap — so it can land at any time, in any order relative to them. (The other three overlap each other only in shared coverage added to |
What does this PR do?
Fixes
websocket: { idleTimeout: 0 }onBun.serve()so it actually disables the idle timeout, as its0value is intended to mean:App.h'sws()validation terminates with"Error: idleTimeout must be either 0 or greater than 8!"for anything in(0, 8)(packages/bun-uws/src/App.h:414-416), proving0is an intentional, distinct off value rather than an ordinary small timeout;WebSocketServerContext.rsalready exempts0from its "round up to 8" clamp for the same reason; and Bun's own inspector/debugger connection (src/js/internal/debugger.ts:316) relies onidleTimeout: 0to mean "never time out".Root cause:
WebSocketContextData::calculateIdleTimeoutComponents()(packages/bun-uws/src/WebSocketContextData.h) computes:idleTimeoutComponents = { idleTimeout - (sendPingsAutomatically ? margin : 0), margin };For
idleTimeout == 0(withsendPingsAutomaticallydefaulting totrue,margin == 4),0 - 4underflows theunsigned shortfield to65532. That value flows straight intous_socket_timeout()(packages/bun-usockets/src/socket.c), which computes a real, finite tick-wheel slot:((65532 + 3) >> 2) % 240 == 63ticks * 4s/tick ≈ 252 seconds. So everyidleTimeout: 0websocket — including Bun's own inspector/debugger connection (src/js/internal/debugger.ts:316), which sets this explicitly to mean "never time out" — got an automatic ping at ~248s and, if unanswered, a force-close (ERR_WEBSOCKET_TIMEOUT) 4s later, instead of never timing out.This is purely an arithmetic bug, not a design gap:
us_socket_timeout(s, 0)already means "disabled" — it sets a255sentinel timeout slot that the tick-wheel sweep (packages/bun-usockets/src/loop.c'sus_internal_timer_sweep,short_ticksalways< 240) can never match.calculateIdleTimeoutComponentsjust never gets to pass the caller's0through — the subtraction corrupts it first. Bun's own config-translation layer already treats0as a distinct sentinel, not an ordinary small timeout:WebSocketServerContext.rsexplicitly exempts0from its "round up to 8" clamp ("uws does not allow idleTimeout to be between (0, 8)... "). This PR finishes that intent at the layer where it actually breaks.Fix (least invasive layer): special-case
idleTimeout == 0incalculateIdleTimeoutComponentsto skip the underflowing subtraction and set the idle-detection component (.first) to0directly. The ping/force-close-after-end()component (.second) is left untouched — still the normalmargin— becauseWebSocket::end()(WebSocket.h) also uses.secondas a short grace period to force-close hung sockets after an app-initiated graceful close, an unrelated concern that must keep working regardless ofidleTimeout. (My first draft of this fix zeroed both components and would have silently disabled that grace period too — caught by re-reading every caller ofidleTimeoutComponentsbefore finalizing.)I checked upstream uWebSockets (
uNetworking/uWebSockets@master, fetched via the GitHub contents API) and itscalculateIdleTimeoutComponents(src/WebSocketContextData.h) has the identical bug, unfixed — so there's no newer upstream special-case to mirror; this fixes it directly in Bun's vendored copy, which is where the arithmetic actually breaks. I considered instead papering over this in the Rust config-translation layer (picking some other magicidleTimeoutvalue that happens to zero out.firstgiven the current margin formula), but rejected it: it would depend on bothsendPingsAutomaticallyand uWS's internal margin-selection algorithm, and would break silently and non-obviously if either changes.Blast radius (grepped
idleTimeoutacrosssrc/andtest/): the only in-tree code relying on this specific websocket-levelidleTimeout: 0semantics — as opposed to the separate, already-correct HTTP-levelidleTimeout: 0(a sibling field ofwebsocket:, not inside it, which passes straight throughAsyncSocket::timeout()/us_socket_timeout()with no arithmetic in between and was never affected) — are:src/js/internal/debugger.ts:316— the inspector/debugger websocket, which explicitly wants "never time out" viaidleTimeout: 0. This is a welcome, straightforward correctness improvement: this connection was previously living on the ~252s ping/force-close cycle described above. (Side note, not fixed here:stryker-bun-runner's prior investigation into intermittent debugger-socket silence flagged this exactidleTimeout: 0config as a contributing-conditions candidate; this fix removes one such candidate but doesn't itself resolve that investigation.)test/js/bun/websocket/websocket-server.test.ts:1368("publish() return value reflects subscriber backpressure" suite) — usesidleTimeout: 0to mean "won't time out during the test's own runtime"; it currently passes only because 252s exceeds the test's duration. Still passes after this fix — now correctly, not just by luck.Everything else in the tree (dozens of occurrences across
test/js/bun/http/*,test/js/web/fetch/*,src/js/node/_http_server.ts,test/js/sql/*) is either the HTTP-levelidleTimeoutor an unrelated SQL connection-pool idle timeout; neither goes throughcalculateIdleTimeoutComponents, so neither is affected by this change.How did you verify your code works?
bun bd -p "Bun.version"builds clean.cd src/js && bun x tsc --noEmit: 1628 errors — identical to the unmodified-mainbaseline (this PR touches no.tsproduction files, only a vendored C++ header and a test file).New regression coverage, appended to the existing
describe/it.concurrentblocks intest/js/bun/websocket/websocket-server.test.ts(describe.concurrent("websocket idleTimeout: 0", ...)) rather than a new file, per this repo's "extend the module's existing test file" convention. Two tests, against a deliberately unresponsive raw-socket client (manual HTTP Upgrade handshake, then silence — no pong, no data):idleTimeout: 8(uWS's minimum reliable granularity, same value used by the existing "should allow use of custom timeout" test inserve.test.ts) still sends an unmasked ping frame (0x89 0x00) and then force-closes the idle connection, within a bounded 20s window — proves the ping/close mechanism itself functions correctly against this harness.idleTimeout: 0, under the exact same conditions, produces neither a ping nor a close within that same 20s window.bun bd test test/js/bun/websocket/websocket-server.test.ts -t "idleTimeout: 0", run 3 times: 2 pass / 0 fail every time (6/6 assertions total), ~20.5s per run.Honesty about this test's limits (asked for explicitly, so stating plainly): I also ran the new tests against system Bun 1.3.14 —
USE_SYSTEM_BUN=1 bun test test/js/bun/websocket/websocket-server.test.ts -t "idleTimeout: 0"— as an unpatched-vs-patched sanity check. Both tests pass there too. This is expected, not a red flag: the historical bug's actual timeout is ~252 seconds, far longer than any wait a test suite should perform, so a 20-second window cannot distinguish "genuinely disabled" (this fix) from "still broken but with a timeout longer than 20s" (the original bug). Only the literal ~252s wait, or a C++-level unit test ofcalculateIdleTimeoutComponentsin isolation, could produce a true red-before/green-after contrast — and Bun has no unit-test harness for the vendored uWS/uSockets C++ sources today (noCMakeLists.txt/gtest target exists forpackages/bun-uwsorpackages/bun-usockets), so that seam isn't available. Given that, this test's actual job — and what it does prove — is to catch a class of future regression (e.g.idleTimeout: 0again being routed through the general subtraction, or the ping/close mechanism itself breaking) via the sanity comparison, not to reproduce the original forensic timing measurement. The fix itself is a 3-line, self-evidently-correct arithmetic special case, reviewed against every caller ofidleTimeoutComponentsinWebSocket.h/WebSocketContext.h/HttpResponse.hto confirm no other code path is affected.Blast-radius gates, all green:
bun bd test test/js/bun/http/serve.test.ts(full file, 255 tests): 0 fail, 1 todo (pre-existing, unrelated).bun bd test test/js/bun/websocket/websocket-server.test.ts(full file, 110 tests): 1 fail —"should work fine if you repeatedly call methods on closed websockets"(spawns a subprocess with GuardMalloc forced on viaforceGuardMalloc), exit code 137 (SIGKILL). Verified this is a pre-existing environment issue, not caused by this PR: it reproduces identically (same exit 137, ~70ms) when run directly against a debug binary built from unmodifiedmainat the same base commit (cc0c1e8355), with none of this PR's changes present — and it passes cleanly against system Bun 1.3.14 (release build). This looks like a debug-build-vs-GuardMalloc memory-overhead interaction specific to this machine, unrelated to idle timeouts, websockets, or anything this PR touches. 3 todo, 106 pass otherwise.bun bd test test/cli/inspect/(41 tests across 4 files): 0 fail, 4 todo (pre-existing).https://claude.ai/code/session_013DexKQwDASzoRVDFS8Jtyn