Repository navigation
Description
Bug Description
While doing a code correctness review of the interceptors, I noticed that interceptors.dump() checks the wrong "aborted" flag in onResponseEnd. As a result, an aborted request whose response body is smaller than maxSize never settles: the request() promise hangs forever (no resolve, no reject). The same abort with a body larger than maxSize correctly rejects with AbortError.
Since maxSize defaults to 1 MiB and most real-world responses are smaller than that, the hang is the common case for aborted requests under this interceptor, not the exception.
Root cause
DumpHandler.onRequestStart replaces the controller's abort method with its own (lib/interceptor/dump.js:31):
controller.abort = this.#abort.bind(this)#abort only sets the handler-local this.aborted = true. The underlying RequestController's private #aborted field is only ever set inside its original abort() method (lib/core/request.js:76-86), which can no longer be reached once the instance property shadows it. So after the hijack, controller.aborted can never become true.
onResponseStart (line 48) and onResponseData (line 72) correctly check the handler-local this.aborted, but onResponseEnd (line 87) checks this.#controller.aborted:
onResponseEnd (controller, trailers) {
if (this.#dumped) {
return
}
if (this.#controller.aborted === true) { // <-- always false, dead condition
super.onResponseError(controller, this.reason)
return
}
super.onResponseEnd(controller, trailers)
}Failure chain for "abort before response, body < maxSize":
- abort →
RequestHandlercallscontroller.abort(reason)→ hijacked method → onlyDumpHandler.aborted = true - response arrives →
onResponseStartswallows it (line 48-50,return true), so the downstreamRequestHandler.onResponseStartis never invoked and the promise stays pending - body (100 B) <
maxSize(512 B) → the correct abort branch inonResponseData(line 69-77) is never reached onResponseEnd→ dead condition isfalse→ forwards a normalonResponseEndto a downstream handler that never sawonResponseStart;this.res?.push(null)is a no-op becauseresisnull- the callback is never invoked → the promise never settles
The existing test test/interceptors/dump-interceptor.js:141 ("Should dump on already aborted request") asserts that an aborted request must reject with AbortError — it only passes because its body (1024 B) happens to exceed maxSize (512 B), hitting the correct check in onResponseData instead.
Reproduction
Standalone reproduction script:
'use strict'
const { test } = require('node:test')
const { createServer } = require('node:http')
const { once } = require('node:events')
const { Client, interceptors } = require('undici')
test('aborted request whose response body is smaller than maxSize should reject', { timeout: 10000 }, async (t) => {
t.plan(1)
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/octet-stream' })
res.end(Buffer.alloc(100)) // 100 bytes < maxSize (512)
})
t.after(() => server.close())
server.listen(0)
await once(server, 'listening')
const client = new Client(`http://localhost:${server.address().port}`).compose(
interceptors.dump({ maxSize: 512 })
)
t.after(() => client.destroy())
const abc = new AbortController()
abc.abort()
await t.assert.rejects(
client.request({ method: 'GET', path: '/', signal: abc.signal }),
{ name: 'AbortError' }
)
})Output on current main (2afeee4):
not ok 1 - aborted request whose response body is smaller than maxSize should reject
failureType: 'testTimeoutFailure'
error: 'test timed out after 10000ms'
Changing Buffer.alloc(100) to Buffer.alloc(1024) (larger than maxSize) makes the same test pass, confirming the inconsistency: the outcome of an abort depends on the response body size.
Expected Behavior
The aborted request rejects with AbortError, exactly like the body > maxSize case and like the existing "Should dump on already aborted request" test asserts.
Actual Behavior
The request() promise never settles. Callers without their own timeout wait forever.
Logs & Screenshots
See reproduction output above.
Environment
- OS: macOS (darwin 25.5.0)
- Node.js version: v22.22.3
- undici version: main @ 2afeee4
Additional context
- Suggested minimal fix: in
onResponseEnd, check the handler-local flag like the other callbacks do, i.e. replacethis.#controller.aborted === truewiththis.aborted === true. I can send a PR with this one-line fix plus a regression test. - Related: fix(interceptor/dump): fix handler lifecycle violation #4634 restructures the dump handler lifecycle, but its diff keeps the
this.#controller.abortedcheck inonResponseEnd, so it does not address this hang (and the broken abort semantics may be contributing to its CI failures).
This report was prepared with AI assistance; I reproduced the issue locally and reviewed every conclusion.
Activity