Skip to content

Backport #5926 to v7.x: aborted fetch with a FormData body crashes the process #6091

Open

Description

@glenn-jocher

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 closed

Expected 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

  1. subhaushsingh commented on Oct 27, 2026

    @subhaushsingh

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions