Repository navigation
http: unref client IncomingMessage.setTimeout timer - #29701
Conversation
The client-side res.setTimeout() override created a ref'd setTimeout
that kept the event loop alive for its full duration after the response
completed. Node delegates to socket.setTimeout() which uses an unref'd
timer, so a keep-alive request followed by res.setTimeout(N) exits
immediately once the body is consumed.
Also bring the override closer to Node semantics:
- return this
- attach callback via .on('timeout', cb)
- msecs <= 0 clears the timer instead of scheduling setTimeout(fn, 0)
|
Updated 12:01 AM PT - Apr 25th, 2026
❌ @robobun, your commit 925ef69 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 29701That installs a local version of the PR into your bun-29701 --bun |
|
Status: addressed review feedback — hoisted Repro: http.get({ host, port, agent: new http.Agent({ keepAlive: true }) }, res => {
res.setTimeout(60000);
res.resume();
});Node exits immediately after CI — Build 47895 (271 pass / 2 fail):
Earlier builds 47855 / 47811: Linked-issue check: neither #23648 (missing |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
WalkthroughReplaced inline Changes
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
LGTM — small, well-scoped Node compat fix with a clear regression test; the stderr assertion nit below is non-blocking.
Extended reasoning...
Overview
This PR touches two files: a ~20-line change to the inline res.setTimeout override in src/js/node/_http_client.ts, and a ~55-line regression test in test/js/node/http/node-http.test.ts. The fix unrefs the timer, returns res, treats msecs <= 0 as clear, and attaches the callback via res.on('timeout', cb) — all of which align with Node's IncomingMessage.prototype.setTimeout semantics.
Security risks
None. This is purely timer ref/unref and event-listener wiring on the client-side response object. No auth, crypto, parsing, or untrusted-input handling is touched.
Level of scrutiny
Low-to-moderate. The change is mechanical and mirrors the existing pattern already used a few hundred lines down in ClientRequestPrototype.setTimeout (which also .unref()s its timer). The behavior change — callback now fires via the 'timeout' event rather than being invoked directly — is strictly more Node-compatible and avoids the previous double-invocation when a user both passed a callback and listened to 'timeout'.
Other factors
The new test spawns a child process and asserts exact stdout + exit code, which would have failed before the fix (BAD_RETURN + STILL_ALIVE). The PR description documents that existing client-timeout-error.test.ts and node-http.test.ts -t setTimeout still pass. No CODEOWNERS cover these paths. The one inline nit (weak stderr assertion) is a test-convention issue only and doesn't affect the soundness of the test, since the exact-stdout and exitCode checks are already strong.
|
Re: the linked issues — tested both against this branch:
This PR only covers the |
There was a problem hiding this comment.
LGTM — the stderr nit was addressed in a8fcf82, and the fix mirrors the existing .unref() pattern already used by ClientRequestPrototype.setTimeout in the same file.
Extended reasoning...
Overview
This PR touches two files: src/js/node/_http_client.ts (~15 net lines changed in the inline res.setTimeout override) and test/js/node/http/node-http.test.ts (one new regression test). The src change makes the client-side IncomingMessage.setTimeout override (1) .unref() its timer, (2) return res, (3) treat msecs <= 0 as clear-only, and (4) register the callback via res.on('timeout', cb) rather than capturing it in the closure. All four are convergence toward Node's documented socket.setTimeout semantics.
Security risks
None. This is event-loop-ref bookkeeping in the Node http client compat layer — no auth, crypto, parsing, or untrusted input handling is touched.
Level of scrutiny
Low-to-medium. The change is small, self-contained, and follows the exact pattern already used ~370 lines lower in the same file (ClientRequestPrototype.setTimeout already does setTimeout(...).unref() and return this). The behavioral deltas are all in the direction of Node compatibility, and the only way this could regress someone is if they were relying on the bug (a ref'd response timeout holding the process open), which is the symptom being fixed. No CODEOWNERS cover these paths.
Other factors
- My previous review flagged a weak
expect(stderr).not.toContain('error')assertion; the author removed it in a8fcf82 and left a comment explaining why stdout + exitCode suffice (debug/ASAN startup banner on stderr). The remaining assertions (stdout === 'end:hello\\n',exitCode === 0) are strong and the test's 2s unref'd sentinel guarantees a fast, deterministic failure on regression rather than a 60s hang. - The PR description documents a stash/unstash gate and confirms the existing
client-timeout-error.test.tsand-t setTimeouttests still pass. - Bug-hunting system found nothing; CodeRabbit found nothing.
node-http.test.ts has an unrelated pre-existing debug/ASAN crash after the exampleSite() TLS test that aborts the run before junit is written. Moving this test to a standalone file isolates it from that.
There was a problem hiding this comment.
LGTM — nit addressed, small Node-compat fix with a focused regression test.
Extended reasoning...
Overview
This PR touches one source file (src/js/node/_http_client.ts, ~15 lines net) and adds one new test file. The source change refactors the inline res.setTimeout override on client IncomingMessage to: (1) .unref() the timer so it doesn't hold the event loop open, (2) return res for chaining, (3) treat msecs <= 0 as clear-only, and (4) register the callback via res.on('timeout', cb) so it fires through the normal emit path. All four are direct Node-compat alignments, and the .unref() pattern already exists a few hundred lines down in ClientRequestPrototype.setTimeout in the same file.
Security risks
None. No auth, crypto, permissions, or untrusted-input handling is touched. The change only affects whether a JS timer is ref'd and how a callback is dispatched on an object the caller already controls.
Level of scrutiny
Low-to-moderate. This is a localized behavioral fix in the Node http client shim with a clear repro and a regression test that fails on main and passes with the fix (verified by the author via stash/unstash). The blast radius is limited to callers of res.setTimeout on client responses — the worst plausible regression (timer never fires because it's now unref'd and nothing else keeps the loop alive) is exactly the Node behavior being matched, and the existing client-timeout-error.test.ts still passes per the PR description.
Other factors
I previously left a nit about a weak not.toContain('error') stderr assertion; the author dropped the stderr check entirely in a8fcf82 with a reasonable rationale (debug/ASAN startup banners), and the remaining exact-stdout + exit-code assertions are sufficient to catch the regression. The test was subsequently moved to its own file in f98aad5, which is cleaner than appending to the already-large node-http.test.ts. No CODEOWNERS cover these paths. No outstanding human review comments.
There was a problem hiding this comment.
LGTM — focused Node-compat fix, prior nit addressed.
Extended reasoning...
Overview
This PR touches one runtime file (src/js/node/_http_client.ts, ~15 lines net) and adds one new test file. It rewrites the inline res.setTimeout override on client IncomingMessage to: register the callback as a "timeout" listener, clear any prior timer, only schedule when msecs > 0, .unref() the timer, and return res. This mirrors the pattern already used a few lines down in ClientRequestPrototype.setTimeout (which already .unref()s its timer), so it's consistent with established code in the same file.
Security risks
None. No user-controlled input parsing, no auth/crypto/permissions surface — this only changes whether a JS timer is ref'd and how the optional callback is attached.
Level of scrutiny
Low-to-medium. It's a Node.js-compat behavioral fix in production code, but the change is mechanical: the previous implementation was clearly wrong (ref'd timer, no return value, no msecs === 0 handling), and the new behavior matches Node's documented socket.setTimeout semantics. The only edge to consider — "does .unref() ever cause a timeout to be missed?" — is the intended behavior: if nothing else is keeping the loop alive, the process should exit rather than wait, exactly as Node does.
Other factors
- My prior nit (weak
stderrsubstring assertion) was addressed in a8fcf82 — the assertion was removed with a reasonable justification (debug/ASAN banners), and the remainingstdout.toBe("end:hello\n")+exitCode.toBe(0)assertions are strong. - The new test passed on all CI shards; the 4 CI failures are documented cross-branch flakes unrelated to
node:http. - The author verified the timeout still fires when the server is slow, listener semantics match Node, and existing
client-timeout-error.test.ts/node-http.test.ts -t setTimeoutstill pass. - No CODEOWNERS entry covers these paths.
There was a problem hiding this comment.
LGTM — small, well-tested Node-compat fix; my earlier nit was addressed.
Extended reasoning...
Overview
This PR touches one production file (src/js/node/_http_client.ts, ~15 lines changed in the inline res.setTimeout override) and adds one new isolated test file. The fix makes the client-side IncomingMessage.setTimeout timer unref'd so it no longer holds the event loop open, adds return res for chainability, treats msecs <= 0 as a clear, and attaches the callback via res.on('timeout', cb) — all of which match Node's documented IncomingMessage.prototype.setTimeout semantics.
Security risks
None. This is timer ref/unref bookkeeping in the Node http client compat layer; no auth, crypto, parsing, or untrusted-input handling is touched.
Level of scrutiny
Low-to-moderate. The change is small, mechanical, and directly mirrors the existing ClientRequestPrototype.setTimeout implementation ~400 lines below in the same file, which already calls .unref() on its timer. The .on('timeout', cb) choice matches Node's actual IncomingMessage.prototype.setTimeout source (which uses on, not once). No CODEOWNERS apply to this path.
Other factors
- The new test is in its own file, spawns a child with a 60s
res.setTimeout+ keep-alive agent, and asserts exact stdout + exit code 0; a 2s unref'd sentinel prevents hangs on regression. It passed on all CI shards. - My earlier nit (weak
stderrsubstring assertion) was addressed by dropping the check entirely, with a reasonable justification (debug/ASAN startup banners) and a precedent cited in-repo. The inline comment is resolved. - The 4 CI failures are documented pre-existing flakes seen across multiple unrelated branches; none touch
node:http. - The PR author explicitly verified against Node, against the unpatched build (fails), and against existing
setTimeouttests.
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Can you hoist the function out so it doesn't acess varaibles from the parent scope and avoids keeping objects alive longer than necessary?
Store the timer on the IncomingMessage via a symbol and use module-level functions so the override doesn't capture (and keep alive) the enclosing ClientRequest's locals for the lifetime of the response.
|
@Jarred-Sumner done in 925ef69 — hoisted to module scope:
No closures over |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/js/node/_http_client.ts`:
- Around line 1053-1056: The clientResponseSetTimeout function currently uses a
truthy check for callback which allows non-function falsy values; change the
logic to explicitly detect when a callback was provided (callback !== undefined)
and call validateFunction(callback, "callback") from internal/validators before
registering it (this will throw the appropriate $ERR_* for invalid types), then
only call this.on("timeout", callback) after validation; keep the existing
behavior of not validating when callback is omitted.
🪄 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: 98587ebd-5c75-41b4-a8a7-8ea15dea9369
📒 Files selected for processing (1)
src/js/node/_http_client.ts
There was a problem hiding this comment.
LGTM — straightforward Node-compat fix to unref the res.setTimeout timer, with a targeted regression test; my earlier nit was addressed.
Extended reasoning...
Overview
This PR rewrites the inline res.setTimeout override on client-side IncomingMessage (in src/js/node/_http_client.ts) as a module-scope function that (1) .unref()s the timer so it doesn't keep the event loop alive, (2) returns this, (3) treats msecs <= 0 as clear-only, and (4) attaches the callback via on('timeout', cb) — all of which match Node's IncomingMessage.prototype.setTimeout → socket.setTimeout semantics. A new test (node-http-res-settimeout-unref.test.ts) spawns a child that calls res.setTimeout(60000) on a keep-alive response and asserts the process exits immediately with exact stdout and exit code.
Security risks
None. This only changes timer ref/unref and listener-attachment semantics on an internal HTTP client helper; no auth, crypto, input parsing, or trust-boundary code is touched.
Level of scrutiny
Low-to-moderate. The source change is ~30 net lines replacing a 13-line closure with an equivalent module-scope function plus .unref(). The behavior delta is strictly closer to Node (the previous implementation already emitted 'timeout' and skipped when res.complete; this preserves both). The hoist to module scope is a mechanical refactor that avoids closing over ClientRequest locals.
Other factors
- My earlier inline nit about
expect(stderr).not.toContain('error')was addressed by dropping the stderr assertion entirely (a8fcf82), with a reasonable rationale (debug/ASAN banner). The remainingstdout.toBe('end:hello\\n')+exitCode.toBe(0)assertions are exact and would fail loudly on regression (BAD_RETURN/STILL_ALIVEin stdout, exit 1). - CI: the new test passed on every shard; remaining failures are documented cross-branch flakes unrelated to
node:http. - No CODEOWNERS coverage for
src/js/node/ortest/js/node/http/. - The bug-hunting system found no issues.
### What does this PR do?
`res.setTimeout(msecs)` on a client-side `IncomingMessage` was creating
a **ref'd** `setTimeout` which kept the event loop alive for the full
timeout duration even after the response completed. In Node.js,
`IncomingMessage.prototype.setTimeout` delegates to
`socket.setTimeout()`, which uses an unref'd timer.
#### Repro
```js
const http = require("http");
const agent = new http.Agent({ keepAlive: true });
http.get({ host, port, agent }, res => {
res.setTimeout(90000);
res.resume();
res.on("end", () => { /* nothing left to do */ });
});
```
Node exits immediately after `end`. Bun (before) waits the full 90s.
#### Cause
`_http_client.ts` overrides `res.setTimeout` on the `IncomingMessage`
with an inline implementation that called `setTimeout(fn, msecs)`
without `.unref()`, without returning `this`, and without treating
`msecs === 0` as "clear the timeout".
#### Fix
- `.unref()` the timer so it doesn't keep the event loop alive
- `return res` (Node returns `this`)
- `msecs <= 0` clears the existing timer instead of scheduling
`setTimeout(fn, 0)`
- attach the callback via `res.on("timeout", cb)` so repeated
`setTimeout` calls stack listeners the same way Node does
#### Relation to "idle keep-alive sockets block exit"
Investigated whether Bun's HTTP keep-alive pool itself refs the event
loop — it doesn't. `http.ClientRequest` goes through native fetch;
pooled sockets live on the HTTP thread's own loop and the only JS-loop
ref is `FetchTasklet.poll_ref`, which is released when the response body
finishes. Plain `http.get` / `https.get` / axios / `agentkeepalive` with
`keepAlive: true` all exit immediately. The one path that produces the
"process waits N seconds after the last request" symptom is this ref'd
`res.setTimeout` timer.
### How did you verify your code works?
New test in `test/js/node/http/node-http.test.ts` spawns a child that
makes a keep-alive `http.get`, calls `res.setTimeout(60000)`, consumes
the body, and asserts the child exits without hitting a 2s sentinel.
- `USE_SYSTEM_BUN=1 bun test` → fails (`BAD_RETURN` + `STILL_ALIVE`)
- `bun bd test` → passes
- `git stash push -- src/ && bun bd test` → fails; `git stash pop && bun
bd test` → passes
Also verified:
- timeout still fires when the server is slow to respond
- listener semantics (`on`, not removed by `setTimeout(0)`) match Node
- existing `test/js/node/http/client-timeout-error.test.ts` and
`node-http.test.ts -t setTimeout` still pass
---------
Co-authored-by: robobun <robobun@users.noreply.github.com>
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
What does this PR do?
res.setTimeout(msecs)on a client-sideIncomingMessagewas creating a ref'dsetTimeoutwhich kept the event loop alive for the full timeout duration even after the response completed. In Node.js,IncomingMessage.prototype.setTimeoutdelegates tosocket.setTimeout(), which uses an unref'd timer.Repro
Node exits immediately after
end. Bun (before) waits the full 90s.Cause
_http_client.tsoverridesres.setTimeouton theIncomingMessagewith an inline implementation that calledsetTimeout(fn, msecs)without.unref(), without returningthis, and without treatingmsecs === 0as "clear the timeout".Fix
.unref()the timer so it doesn't keep the event loop alivereturn res(Node returnsthis)msecs <= 0clears the existing timer instead of schedulingsetTimeout(fn, 0)res.on("timeout", cb)so repeatedsetTimeoutcalls stack listeners the same way Node doesRelation to "idle keep-alive sockets block exit"
Investigated whether Bun's HTTP keep-alive pool itself refs the event loop — it doesn't.
http.ClientRequestgoes through native fetch; pooled sockets live on the HTTP thread's own loop and the only JS-loop ref isFetchTasklet.poll_ref, which is released when the response body finishes. Plainhttp.get/https.get/ axios /agentkeepalivewithkeepAlive: trueall exit immediately. The one path that produces the "process waits N seconds after the last request" symptom is this ref'dres.setTimeouttimer.How did you verify your code works?
New test in
test/js/node/http/node-http.test.tsspawns a child that makes a keep-alivehttp.get, callsres.setTimeout(60000), consumes the body, and asserts the child exits without hitting a 2s sentinel.USE_SYSTEM_BUN=1 bun test→ fails (BAD_RETURN+STILL_ALIVE)bun bd test→ passesgit stash push -- src/ && bun bd test→ fails;git stash pop && bun bd test→ passesAlso verified:
on, not removed bysetTimeout(0)) match Nodetest/js/node/http/client-timeout-error.test.tsandnode-http.test.ts -t setTimeoutstill pass