Repository navigation
Conversation
|
Updated 11:35 AM PT - May 21st, 2026
❌ @robobun, your commit f0a3ea3 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 31170That installs a local version of the PR into your bun-31170 --bun |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughCentralizes http.request options.signal wiring, emits an AbortError on next tick using signal.reason as cause before socket cleanup and legacy "abort", installs a noop error listener to avoid uncaught exceptions, and adds tests for in-flight, pre-aborted, post-response, and event-ordering cases. ChangesAbortSignal handling in HTTP client
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
8bd80ef to
cb0a1eb
Compare
There was a problem hiding this comment.
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 `@src/js/node/_http_client.ts`:
- Around line 268-276: abortHandler currently calls
this[kAbortController]?.abort?.() and then directly calls onAbort(), causing
duplicate abort logic and potential duplicate "error" events or late AbortError
emissions after request completion; change abortHandler to only call
this[kAbortController].abort() (or, if there is no controller, call onAbort()),
and guard onAbort from emitting after the request is finished by checking
this[abortedSymbol] or request completion state before calling
process.nextTick(emitAbortErrorNextTick, ...). Update references in abortHandler
and any similar handlers (see other occurrence around lines 739-742) so abort is
triggered once via the controller signal and onAbort only runs when the request
is still active, using the existing symbols: this[kAbortController], onAbort,
this[kSignal], abortedSymbol, emitAbortErrorNextTick, and $makeAbortError.
🪄 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: 87c7070d-7185-4ff8-bc8d-fa7f2ad10b75
📒 Files selected for processing (3)
src/js/node/_http_client.tstest/js/node/http/client-signal-abort.test.tstest/js/node/http/node-http.test.ts
There was a problem hiding this comment.
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/client-signal-abort.test.ts`:
- Around line 101-102: Replace the arbitrary delay using setTimeout in the test
with Bun.sleep(0) so the macrotask barrier drains the process.nextTick/microtask
queue; locate the await new Promise(r => setTimeout(r, 50)) line in the test (in
the client-signal-abort.test) and change it to await Bun.sleep(0) to follow the
guideline of avoiding setTimeout in tests.
🪄 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: 86fb050a-f5c0-4f8c-a6a6-e5358fba67bf
📒 Files selected for processing (2)
src/js/node/_http_client.tstest/js/node/http/client-signal-abort.test.ts
687016a to
4329461
Compare
When `options.signal` passed to `http.request()` aborts, Bun previously
only emitted 'abort' and 'close' — callers awaiting `req.on('error',
reject)` hung until the underlying socket timed out. Now mirrors Node:
- Signal fires mid-request: emit 'error' with an AbortError
(name 'AbortError', code 'ABORT_ERR', cause = signal.reason),
then 'close', then the legacy 'abort' event, all on one nextTick
so stream consumers that settle on 'close' see the error first.
- Signal already aborted at construction: same sequence, scheduled
on nextTick.
- Signal fires after the response has completed: no-op (a late-firing
`AbortSignal.timeout(N)` used as a request deadline must not
spuriously error a request that already succeeded).
Matches Node's `addAbortSignal` side effect of installing an internal
'error' listener so the synthesised AbortError doesn't become an
uncaught exception when the user hasn't attached their own handler.
Fixes #31167
4329461 to
58906bf
Compare
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 `@src/js/node/_http_client.ts`:
- Around line 290-307: The onAbort handler currently still runs teardown logic
for late aborts and the options.signal listener isn't removed on request settle;
update onAbort (and the settle/destroy paths that register the listener) so that
if this[kSignal]?.aborted fires after the response is complete it becomes a true
no-op (return immediately without calling socketCloseListener or toggling
this[abortedSymbol]), and ensure the options.signal listener is explicitly
removed when the request settles or is destroyed (use
signal.removeEventListener/removeListener where the listener was originally
registered). Tie the removal to the same settle/cleanup code paths that call
this[kClearTimeout] and socketCloseListener, and keep the existing
emitSignalAbortNT + $makeAbortError behavior only for aborts that occur before
res.complete; reference onAbort, options.signal, this[kSignal],
this[abortedSymbol], socketCloseListener, emitSignalAbortNT and $makeAbortError
when making the changes.
In `@test/js/node/http/client-signal-abort.test.ts`:
- Around line 134-135: The test currently only checks ordering with
expect(events.indexOf("error")).toBeLessThan(events.indexOf("close")) which can
pass if "error" is missing; update the assertion to first assert both events
exist (e.g., expect(events).toContain("error") and
expect(events).toContain("close")) and then assert ordering using the same
indexOf checks; locate the failing line in the test that calls
expect(events.indexOf("error")).toBeLessThan(events.indexOf("close")) and add
the two presence assertions immediately before it.
🪄 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: 3f0b7d72-13de-4a55-8cb9-db0705d62c7a
📒 Files selected for processing (2)
src/js/node/_http_client.tstest/js/node/http/client-signal-abort.test.ts
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/js/node/_http_client.ts:739-742— Related to the earlier comment on line 274, but a distinct trigger that the suggested&& !this?.res?.completeguard does not cover:const req = http.request(url, {signal}); req.destroy()(never callingend()). HerekAbortControlleris still null so the pre-PR listener was a complete no-op, but the newabortHandlernow callsonAbort()directly;destroy()sets neitherabortedSymbolnorres, so when the signal later firesonAbortschedules an unguardedemit('error', AbortError)on a request the user already tore down — an uncaught exception if no listener is attached. The guard at line 274 also needs&& !this.destroyed(or remove the signal listener when the request settles, as Node does viaeos()).Extended reasoning...
What the bug is
The new
abortHandler(src/js/node/_http_client.ts:739-742) callsonAbort()directly afterthis[kAbortController]?.abort?.(). Before this PR the listener was just() => this[kAbortController]?.abort(), which was a complete no-op wheneverkAbortControllerwas null — i.e. wheneverstartFetch()had not yet run. The directonAbort()call removes that implicit short-circuit, so a user-signal abort now drivesonAborteven on a request that was destroyed before any I/O started.This is closely related to the earlier comment on line 274 (spurious error after a successful response), but it is a distinct trigger that the fix suggested there does not cover. That comment proposed adding
&& !this?.res?.completeto the guard; in this scenarioresisundefined, so!this?.res?.completeevaluates to!undefined→trueand the error still fires.Code path
const req = http.request(url, { signal: AbortSignal.timeout(1000) }); req.destroy(); // user changes mind, never calls end() // ...1s later the timeout fires
Step-by-step proof
http.request(url, {signal}): signal is not yet aborted, sosignal.addEventListener('abort', abortHandler, {once:true})is registered (line 746).this[kAbortController] = null(line 723).startFetchnever runs becauseend()/write()/flushHeaders()are never called.req.destroy()(lines 218-237): setsdestroyed = true,finished = true.this[kAbortController]?.abort?.()is a no-op onnull.this.socket.destroy()runs on the lazy FakeSocket. Crucially:abortedSymbolstays unset (onlyreq.abort()or the block at line 278-281 set it),_closedstaysfalse, and the signal listener is never removed.- t=1000ms — signal fires →
abortHandlerruns →this[kAbortController]?.abort?.()is a no-op (null) →onAbort()called directly. - In
onAbort(line 274):signal.aborted === trueand!this[abortedSymbol] === true→ schedulesprocess.nextTick(emitAbortErrorNextTick, this, abortError). ThensocketCloseListener()emits'close'(since_closedwas false). Then line 278:!this[abortedSymbol] && !this?.res?.complete→true && !undefined→true→ schedules'abort'. - nextTick:
emitAbortErrorNextTick(line 1094) unconditionally callsself.emit('error', err)— it deliberately bypasses thedestroyedcheck. With no'error'listener (typical when the user themselves calleddestroy()and considers the request done), this throws as an uncaught exception from insideprocess.nextTick.
Why none of the existing/proposed guards help
Guard Value here Blocks? !this[abortedSymbol]true(never set)❌ !this?.res?.complete(comment on line 274)!undefined=true❌ !this.finished(CodeRabbit)false— would block, but also blocks the primary fix sincereq.end()setsfinished=truebefore an in-flight abort❌ wrong guard Node.js avoids this entirely by removing the signal listener via
eos()once the request settles (including ondestroy()), so the late signal is a no-op there.Impact
This is a regression introduced by this PR: pre-PR Bun did nothing in this scenario (the old listener no-opped on a null controller). Post-PR, a user who creates a request with a deadline signal and then explicitly destroys it (e.g. external cancellation, retry logic that gives up) gets an uncaught
AbortErrorwhen the deadline later expires — potentially crashing the process. Less common than theres.completecase from the other comment, but still a real-world pattern.How to fix
Either extend the guard at line 274 to also check
!this.destroyed:if (signal?.aborted && !this[abortedSymbol] && !this.destroyed && !this?.res?.complete) { process.nextTick(emitAbortErrorNextTick, this, $makeAbortError(undefined, { cause: signal.reason })); }
…or, more robustly, remove
abortHandlerfromoptions.signalonce the request settles (success, error, or destroy), matching Node'seos()-based cleanup. The latter also fixes theres.completecase from the other comment and avoids leaking the listener on long-lived/shared signals.
Review follow-up: the signal's `abort` listener was attached with
`{ once: true }` but nothing removed it when the request settled, so a
long-lived signal could still drive `onAbort()` on a completed or
destroyed request.
With the `!res.complete` guard added in the previous commit the late
signal on a successful response was already a no-op, but a request that
the caller destroyed before calling `end()` (common external-cancellation
pattern) had neither `res` nor `abortedSymbol` set, so a later signal
firing still scheduled a spurious `AbortError`.
Mirror Node's `eos()`-based cleanup: stash the `removeEventListener`
call on a closure-local `removeSignalListener` and invoke it from
`socketCloseListener`, `req.destroy()`, and `maybeEmitClose`. Also
addresses a minor review note: the ordering assertion now checks
presence of both `error` and `close` before indexOf-comparing.
Replace the wall-clock `Bun.sleep(50)` in the destroy-then-signal test
with a direct `signal.addEventListener("abort", …)` await so the
assertion only runs after the late-abort path has had its chance to
(wrongly) fire. Previously the 50ms wait vs a 20ms timeout relied on
a timing race and would trivially pass if the signal hadn't fired yet,
letting a regression slip through.
`events.once(signal, "abort")` isn't usable here — AbortSignal is an
EventTarget, and `once()` from `node:events` hangs on one after every
listener has been removed (matches Node's behaviour). Use a dedicated
`addEventListener` instead.
Review follow-up: `removeSignalListener()` was wired into the three
happy-path settle points (`socketCloseListener`, `destroy`,
`maybeEmitClose`) but the fetch-rejection `.catch` and the
`options.lookup` error/fail paths weren't covered. A request that
settled with ECONNREFUSED / ENOTFOUND / TLS-handshake-error and
carried a long-lived `AbortSignal.timeout(N)` as a deadline would
therefore emit a second `'error'` (AbortError) when the signal fired
later — user `'error'` handlers saw two different errors for one
request.
Drop the signal listener on those paths, mirroring the cleanup Node
performs via `socket.destroy(err)` → Writable destroy → `eos()`.
Also correct the misleading comment on the internal `noop` error
listener: Node's `addAbortSignal` itself doesn't install one, but
`ClientRequest.listenerCount('error')` still ends up at 1 for a
signal-bearing request via Node's `eos()` wiring. Bun mirrors that
end state with the `noop`. User-provided `'error'` listeners still
fire alongside it, so user code is unaffected.
Regression test covers the connection-failure / late-signal case.
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/js/node/_http_client.ts:691-694—maybeEmitClose()runs on the nextTick immediately afteremit('response', res)(line 496) — i.e. when only headers have arrived and the body is still streaming — so callingremoveSignalListener()here detachesoptions.signalbefore the response completes. A signal that fires during body streaming (e.g.AbortSignal.timeout(N)on a slow/hung response) then becomes a complete no-op:abortHandleris gone,kAbortControlleris never aborted, and the response can no longer be cancelled — a regression vs both pre-PR Bun and Node (which keeps the listener untilresponseOnEnd). The!this?.res?.completeguard inonAbortalready handles the post-completion late-fire case this call was added for, so this line can simply be dropped (or gated onthis.res?.complete).Extended reasoning...
What the bug is
maybeEmitClose()(src/js/node/_http_client.ts:689) is invoked from the nextTick scheduled byhandleResponse()— specifically from thefinally { if (self.finished) maybeEmitClose(); }block at line 494-496, which runs immediately afterself.emit('response', res). At that moment only the response headers have arrived; the body is still being streamed asynchronously through theIncomingMessage(driven by the fetch bodyReadableStream), andres.completeis stillfalse.Commit 9e90768 added
removeSignalListener()at line 694 insidemaybeEmitClose(), unconditionally and before the!this._closedcheck. This detachesabortHandlerfrom the user'soptions.signalthe instant headers arrive. From that point until the body finishes, firing the signal is a complete no-op:abortHandlernever runs,this[kAbortController]is never aborted, the underlying fetch keeps streaming, andonAbort()is never reached.Why this is a regression introduced by this PR
Before this PR the user-signal listener was never removed (the diff shows it was a plain
{ once: true }addEventListenerwith noremoveEventListeneranywhere). A signal firing while the response body was streaming reachedthis[kAbortController]?.abort()→ fetch body aborted + the controller'sonAbortlistener →socketCloseListener()→res.destroy(new ConnResetException('aborted')). The response stream was torn down and the user saw an error/abort.After this PR the listener is gone before the body starts arriving, so the same signal does nothing — a slow, large, or hung response body can no longer be cancelled via
options.signalonce headers have been received.AbortSignal.timeout(N)as a request deadline silently stops working past the headers point.Node.js wires the signal up via
addAbortSignaland removes it withthis.once('close', disposable[SymbolDispose]), where ClientRequest'close'fires only afterresponseOnEnd()(i.e. after the body completes). So in Node the signal can abort throughout body streaming. Bun already emitted req'close'early (a pre-existing divergence), but couplingremoveSignalListener()to that early point turns the divergence into a functional regression.Why existing guards don't help
- The
!this?.res?.completeguard inonAbort(line 307) is irrelevant —onAbortis never reached becauseabortHandlerwas removed from the signal. signalAbortHandledis irrelevant for the same reason.- The other two
removeSignalListener()call sites (socketCloseListenerline 256,this.destroy()line 228) are fine — they run on actual teardown. Only themaybeEmitClose()site fires prematurely. - None of the new tests exercise the during-body window: test #3 ("does not emit error when the signal fires after the response completes") aborts only after
res.on('end')fires, so it never covers the headers-arrived-body-streaming window.
Step-by-step proof
const server = http.createServer((_, res) => { res.write('chunk'); // headers + first chunk; never end() }); const req = http.get(url, { signal: AbortSignal.timeout(200) }, res => res.resume()); req.on('error', e => console.log(e.code));
- t≈1ms —
nodeHttpClient(...).then(response => ...)resolves with headers.handleResponse()schedules a nextTick. - nextTick —
self.emit('response', res)fires;finallyblock runs;self.finishedistrue(set synchronously bysend()whenreq.end()/http.getran) →maybeEmitClose()→removeSignalListener()detachesabortHandler→ schedulesemitCloseNTAndComplete.res.completeis stillfalse; body is still streaming. - t=200ms — timeout signal fires → no listener attached → nothing happens.
kAbortControlleris never aborted; the fetch body stream keeps reading;onAbortnever runs. Request hangs indefinitely. - Pre-PR / Node: at t=200ms,
abortHandlerruns →kAbortController.abort()→ fetch body aborts +onAbort→socketCloseListener()→res.destroy(ConnResetException('aborted'))→ user sees the error.
Impact
Any code that passes
{ signal: AbortSignal.timeout(N) }(or anAbortControllersignal) to bound total request time — a very common pattern — loses the ability to cancel once headers arrive. A server that sends headers and then stalls (or trickles a large body) will hang the client past the deadline. This is strictly worse than both pre-PR Bun and Node.How to fix
The
removeSignalListener()call at line 694 is redundant for its stated purpose — the!this?.res?.completeguard inonAbort(line 307) already makes a post-completion late signal a no-op (it skips theemitSignalAbortNTbranch whenres.completeis true), andsocketCloseListener()already callsremoveSignalListener()on actual teardown. Simply dropping line 691-694 restores the during-body cancellation behaviour while keeping the late-fire protection intact.If listener cleanup on the success path is desired for memory/retention reasons, it should be deferred until
res.completebecomes true (e.g. hooked fromemitEOFIncomingMessageorres.on('end')), not run at headers time. - The
Review follow-up: `maybeEmitClose()` runs on the nextTick *after the response headers arrive*, while the body is still streaming (`res.complete === false`). The previous commit called `removeSignalListener()` there, which made `options.signal` a no-op for the remainder of the response: a server that sent headers and then stalled or trickled the body could no longer be cancelled via the signal once the first chunk had been dispatched. The `!res.complete` guard in `onAbort` already prevents a spurious AbortError from firing after the response completes, and the listener is still removed on real teardown (`socketCloseListener`, `req.destroy()`, and the network-error settle paths), so the `maybeEmitClose` cleanup was redundant as well as harmful. Drop it and leave a comment so future edits don't re-introduce it. Regression test manually aborts during the body-streaming window (after a first chunk has been received, before `res.end()`).
|
Addressed claude[bot]'s latest finding in a384341 — dropped |
Review follow-up from claude[bot]: - Add `removeSignalListener()` to the two remaining settle paths that were missed in 9901e02 — the outer `try { options.lookup(...) } catch` in `startFetch()` and `send()`'s `try { startFetch(...) } catch`. Both are synchronous-throw catches that emit 'error' directly; without the listener removal, a later signal firing would double-emit an AbortError on a request that already terminally failed. New regression test covers the `options.lookup` throw case. - Replace the flaky sequencing in the connection-failure test (`does not re-emit 'error' after a connection failure…`) with an explicit `AbortController` so it awaits the ECONNREFUSED first, then fires the abort deterministically — matching the sibling tests and the in-file comment warning against the `AbortSignal.timeout()` race pattern. - Drop the enumeration in the comment next to `removeSignalListener` — it had already rotted from two callers to six across two commits in this PR. Describe the intent instead.
|
CI #56650, #56658, and #56665 all red on the same pre-existing unrelated lane:
Already spent my one |
Review follow-up from claude[bot]:
- Hook `removeSignalListener()` into `res.once('end', …)` so a
long-lived shared signal (e.g. a process-wide shutdown
AbortController) doesn't retain every successful request's closure
for the rest of the signal's life. The previous code only cleaned
up on abort/destroy paths; the normal success path left the
listener attached. Pre-existing leak — the old inline arrow
listener had the same property — but easy to close now that the
wiring is already in place.
- Rewrite the stale comment at `maybeEmitClose` accordingly.
- Document the pre-existing ordering divergence in the body-streaming
abort window: Bun's early-`'close'` on headers-arrived means a
signal firing after data starts flowing produces `close → error →
abort` rather than Node's `error → close`. The root cause is
unrelated to this PR (it's the headers-arrived close behaviour),
and fixing it touches pre-existing Bun code outside scope; note
it in `emitSignalAbortNT` so future readers don't re-litigate.
Review follow-up from claude[bot]: When a signal aborts while happy-eyeballs `iterate()` is mid-connect, the inner `.catch` early-returns on `isAbortError` but the outer `.catch(iterate)` still fires on the same underlying promise, draining the candidate list and landing in `fail()` — which then emits a spurious ECONNREFUSED on top of the AbortError that `emitSignalAbortNT` has already scheduled. Guard `fail()` on `this[abortedSymbol] || this.destroyed` so only the AbortError reaches the user. Regression test covers the pre-aborted + multi-candidate lookup case — fails without the guard with `[AbortError, ECONNREFUSED]`. Also trim the misleading `events.once()` rationale in the destroy-before-end test comment: `events.once` does support EventTarget via the fall-through branch, so the hand-rolled listener is a stylistic choice, not a workaround. Keep just the intent.
…mit paths Review follow-up from claude[bot]. Three adjacent double-emit cases were leaking past the per-path guards added in earlier commits: 1. Pre-aborted signal + sync-throwing `options.lookup`: the pre-aborted branch schedules `abortHandler` on nextTick but `removeSignalListener` can't cancel a queued `process.nextTick` that was never attached as a listener, so the catch block's cleanup is a no-op for pre-aborted signals. Result: lookup-error + AbortError. 2. Signal races an async `options.lookup` that then errors: the err-first callback at the top of `startFetch`'s lookup call had no `abortedSymbol`/`destroyed` check (the sibling `fail()` got one in 3a997a9, but this earlier branch was missed). Result: AbortError + lookup error. 3. Happy-eyeballs last-candidate network failure (pre-existing, no signal involved): `go()` returns the raw fetch promise and attaches its inner `.catch(emitError).finally(...)` as a side branch, so `iterate()`'s `.catch(iterate)` sees the same rejection, drains to empty, and `fail()` fires a second ECONNREFUSED. The new `fail()` guard only checks `abortedSymbol`/`destroyed`, neither of which the inner `.catch` sets. All three collapse to the same invariant: at most one terminal `'error'` per request. Introduce a closure-local `errorEmitted` latch and gate every `'error'` emission path on it. This subsumes the existing `abortedSymbol`/`destroyed` check in `fail()`, makes the cleanup `removeSignalListener()` calls on the error paths idempotent-by-design, and closes the pre-existing iterate dual-catch without needing to restructure the promise chain. Three new regression tests (all fail without the latch, pass with it).
The err-first branch of the async `options.lookup` callback only checked `errorEmitted`, while the sibling `fail()` six lines below uses `abortedSymbol || destroyed || errorEmitted`. `errorEmitted` covers signal-driven teardown (via `emitSignalAbortNT`) but not user-driven `req.destroy()` / `req.abort()` (which set only `destroyed` / `abortedSymbol`), leaving a residual gap where an async lookup that errors after a user-driven teardown emits on a closed request. Mirror `fail()`'s three-way guard so the two adjacent branches handle late-arriving errors consistently. Regression test covers the user-driven-destroy + late-lookup-error path.
…p(10) Align with the sibling tests in this file: resolve a promise from inside the lookup callback and `await lookupFired; await Bun.sleep(0);` instead of a 10ms wall-clock wait. Also drop the `since 3a997a9` clause from the test comment — PR-branch SHAs don't survive squash-merge.
|
Heads up: #31587 landed on main and rewrote the HTTP client on net/tls + llhttp, replacing the fetch-based implementation this PR patched. The new client wires Checking whether the rewrite already fixes #31167 by running this PR's regression suite against current main. If it passes clean, this PR is obsolete and I'll close it; if gaps remain, I'll port the fix onto the new client. |
|
Closing this as obsolete. #31587 rewrote the node:http client on net/tls + llhttp and replaced the fetch-based implementation this PR patched. I verified against current main (454e3b2) that the rewrite already fixes #31167: the exact issue scenario now emits the AbortError as expected. The rewrite wires I ran this PR's 13-test suite against main: the 6 core-behavior tests pass, and the 4 that fail were all asserting the old fetch-based client's behavior, which diverged from Node. I confirmed each against Node on main:
So the new client is strictly more Node-compatible here, and there's nothing left to port. The code this PR changes no longer exists. |
Fixes #31167
Reproduction
Cause
In
src/js/node/_http_client.ts, theoptions.signallistener only forwarded the abort to the internalAbortController, and the internalonAbortonly emitted'abort'+'close'. The fetch-promise.catchpath explicitly swallowedAbortErrorwith the comment "The 'abort' listener on the abort controller should have called this" — but nothing ever emitted an'error'event. A second gap: ifsignal.abortedwas alreadytruewhenhttp.request()was called,addEventListener("abort", …)was never fired and no abort ever happened.Fix
options.signalfires, emit'error'with\$makeAbortError(undefined, { cause: signal.reason })(Node-compatible:name: 'AbortError',code: 'ABORT_ERR',cause= the signal's reason), then runsocketCloseListenerandabort, all on onenextTick. This gives the Node-matching event ordererror→close→abort.res.completeis true — a late-firingAbortSignal.timeout(N)used as a request deadline must not fire'error'on a request that already succeeded. The old iteration double-emitted here becauseabortHandlercalls bothkAbortController.abort()andonAbort()directly, and the controller fires its listener once.signalAbortHandledlatch + eagerabortedSymbolset prevents the redundant directonAbort()call from re-enteringsocketCloseListener.process.nextTickso the same error is delivered.req.abort()/req.destroy()paths are unaffected — they setabortedSymbolbefore calling the internalAbortController, so the signal-specific branch never runs for them.Verification
Four tests in a new
test/js/node/http/client-signal-abort.test.ts(underdescribe("http.request options.signal")):'error'is emitted withAbortError/ABORT_ERR/cause.name === 'TimeoutError'.'error'still emitted with the supplied reason ascause.errorbeforeclosewhen the signal fires.(Kept in its own file to isolate from pre-existing neighbor failures in
node-http.test.ts; the existing"should attempt to make a standard GET request and abort"test in that file gains a no-op'error'listener to swallow the now-emitted AbortError.)Error shape cross-checked against Node 24: