Repository navigation
Backport #5926 to v7.x: aborted fetch with a FormData body crashes the process #6091
Description
Bug Description
fetch() with a FormData body and an already-aborted AbortSignal rejects with AbortError as expected, then also raises an unhandled rejection, ERR_INVALID_STATE: Invalid state: ReadableStream is already closed. Under the default --unhandled-rejections=throw, that exits the process.
The cause is the one described in a comment on #5926. Since #4791 (7.20.0), extractBody enqueues FormData parts from a detached async loop that checks isErrored(stream) but not whether the stream is closed. abortFetch cancels the request body, which closes the stream, and the loop's next enqueue throws with nothing awaiting it.
#5926 fixed this on main (8.12.0), but it has not been backported to v7.x. Node.js 24 LTS bundles 7.30.1, so its global fetch still crashes.
Servers hit this easily. A handler that forwards its incoming request's signal to an outgoing fetch with a FormData body crashes when the client disconnects before that call. The crash also ends every other request in flight on that process.
Reproduction
Standalone reproduction script:
'use strict'
const { test } = require('node:test')
const assert = require('node:assert')
const { createServer } = require('node:http')
const { once } = require('node:events')
const { fetch, FormData } = require('undici')
test('fetch with a FormData body and an aborted signal rejects without an unhandled rejection', { timeout: 60000 }, async (t) => {
const unhandled = []
const onUnhandledRejection = (err) => unhandled.push(err.message)
process.on('unhandledRejection', onUnhandledRejection)
t.after(() => process.off('unhandledRejection', onUnhandledRejection))
const server = createServer((req, res) => req.resume().on('end', () => res.end('ok')))
t.after(() => server.close())
server.listen(0)
await once(server, 'listening')
const form = new FormData()
form.set('a', 'b')
const controller = new AbortController()
controller.abort()
await assert.rejects(
fetch(`http://localhost:${server.address().port}`, { method: 'POST', body: form, signal: controller.signal }),
{ name: 'AbortError' }
)
// Let the detached body loop run against the cancelled stream.
await new Promise((resolve) => setTimeout(resolve, 50))
assert.deepStrictEqual(unhandled, [])
})The global fetch in Node.js 24 crashes the same way without node:test:
const ac = new AbortController()
ac.abort()
const form = new FormData()
form.set('a', 'b')
await fetch('http://localhost:1', { method: 'POST', body: form, signal: ac.signal }).catch(() => {})
// process exits: Invalid state: ReadableStream is already closedExpected Behavior
fetch rejects with AbortError, and nothing else happens.
Actual Behavior
fetch rejects with AbortError, then:
TypeError [ERR_INVALID_STATE]: Invalid state: ReadableStream is already closed
at ReadableByteStreamController.enqueue (node:internal/webstreams/readablestream:1277:13)
at node_modules/undici/lib/web/fetch/body.js:231:24
at process.processTicksAndRejections (node:internal/process/task_queues:104:5) {
code: 'ERR_INVALID_STATE'
}
Environment
- OS: macOS 27 locally, and Linux in production
- Node.js version: v24.22.1 (bundled undici 7.30.1) and v26.5.1 (bundled undici 8.9.0) both crash with the global
fetch - undici version: 7.30.1
Additional context
| undici | Repro test |
|---|---|
| 7.30.1 | fail |
v7.x at 0eec414 |
fail |
v7.x at 0eec414 + git cherry-pick e049c53b (#5926) |
pass |
| 8.11.0 | fail |
| 8.12.0 | pass |
e049c53b cherry-picks onto v7.x without conflicts. With it applied, the test above passes, and so do test/fetch/{client-fetch,formdata,request,response,abort,abort2}.js (113/113, run with --expose-gc). Node.js main already ships 8.12.2 (nodejs/node#66721), so only the v7.x line still needs the fix.
Could #5926 get the backport v7.x label?
Activity
subhaushsingh commentedon Oct 27, 2026
I can open the v7.x backport PR for this. I’ll cherry-pick e049c53 (#5926) onto v7.x, confirm the repro fails before and passes after, and run the fetch tests.