Skip to content

request() with a cross-realm FormData (e.g. Node's global) hangs forever instead of erroring; via the legacy globalDispatcher claim this also breaks undici v6 clients in the same process #5520

Description

@Pyker

Summary

undici.request() half-accepts a FormData that isn't undici's own class: the client's duck-typed gate routes it into body extraction, the brand check inside extractBody then rejects it, and the request is dispatched with a null body that never completes. No error is raised; the request hangs until some external timeout (server idle timeout, caller watchdog, etc.).

On v7.20.0+/v8 this is reachable two ways (v7.0.0 through 7.19.x fail loudly instead; see the version table below):

  1. Standalone: request(url, { body: formData }) where formData is Node's globalThis.FormData (the most common way user code constructs one) hangs. undici's own FormData works.
  2. Cross-copy, via the eager legacy-dispatcher claim (same mechanism as Importing undici v8 silently breaks Node's bundled fetch via the legacy globalDispatcher bridge (v8's stricter core Request rejects opts the bundled v6 core accepts — e.g. request-set Content-Length) #5500): a bare require('undici') anywhere in the process captures Symbol.for('undici.globalDispatcher.1') (every major does guarded eager init at require time; since 7.27.0 a Dispatcher1Wrapper is mirrored into the legacy slot, and v8's init guard only checks its own .2 slot, so it overwrites .1 even when another copy already owns it, making both require orders break). Any undici v6 client in the same process (e.g. @discordjs/rest, which pins undici 6.x) then dispatches through the newer core, where its FormData bodies, perfectly valid for v6, hit the same silent hang. The affected code never uses v7/v8 APIs and cannot work around it by "using undici's FormData".

Real-world impact of (2): adding undici 8.7.0 as a direct dependency of a discord.js bot made every file-attachment upload time out (AbortError: This operation was aborted after @discordjs/rest's 15s watchdog, 4 attempts) while all JSON requests kept working. This took a while to trace because the breakage is caused by a bare require in an unrelated module.

Reproduction 1: standalone (no version mixing)

// npm i undici@8.7.0
const http = require('node:http');
const { request } = require('undici');

const server = http.createServer((req, res) => {
  req.on('data', () => {});
  req.on('end', () => res.end('{}'));
});

server.listen(0, async () => {
  const form = new FormData(); // Node's global FormData
  form.append('file', new Blob(['hello world'], { type: 'text/plain' }), 'hello.txt');
  const res = await request(`http://localhost:${server.address().port}/`, {
    method: 'POST',
    body: form,
  }); // <- never resolves, never rejects
  console.log('status', res.statusCode); // never reached
});

Replace new FormData() with new (require('undici').FormData)() and it completes with a correct multipart body.

Reproduction 2: bare require of v8 breaks an undici v6 client

// npm i undici6@npm:undici@6.27.0 undici8@npm:undici@8.7.0
const http = require('node:http');
require('undici8'); // bare require, never used again; remove this line and everything works
const { request } = require('undici6');

const server = http.createServer((req, res) => {
  req.on('data', () => {});
  req.on('end', () => res.end('{}'));
});

server.listen(0, async () => {
  const form = new FormData();
  form.append('file', new Blob(['hello world'], { type: 'text/plain' }), 'hello.txt');
  const res = await request(`http://localhost:${server.address().port}/`, {
    method: 'POST',
    body: form,
  }); // <- hangs; undici 6 alone handles this fine
});

diagnostics_channel shows undici:request:create and undici:client:sendHeaders firing, but undici:request:bodySent never does. Despite the sendHeaders event, a raw TCP-level check shows zero bytes ever reach the wire: the socket output is held back behind the body that never comes, so the server just sees an idle connection until something times out.

Root cause

Two checks disagree about what a FormData is:

  • lib/dispatcher/client-h1.js routes the body into form-data extraction based on the duck-typed check util.isFormDataLike(body) (structural: append/get/set methods plus Symbol.toStringTag === 'FormData'), which accepts FormData from any realm: Node's builtin, another undici copy, a polyfill.
  • lib/web/fetch/body.js extractBody() gates the actual multipart encoding on the brand check webidl.is.FormData(object), which only accepts instances of this copy's own FormData class.

For a cross-realm FormData the duck check passes and the brand check fails, so extractBody falls through every branch and returns an empty descriptor. This is observable directly:

const { extractBody } = require('undici/lib/web/fetch/body.js');
const fd = new FormData(); // Node's global
fd.append('file', new Blob(['hello']), 'hello.txt');
const [body, contentType] = extractBody(fd);
// body.source = null, body.length = null, contentType = null,
// body.stream = a ReadableStream that never produces data and never closes.
// With undici's own FormData: multipart source, correct length,
// contentType 'multipart/form-data; boundary=...'.

client-h1 then queues the headers and waits on that never-producing stream with contentLength = null: nothing reaches the wire, nothing errors.

Affected versions (verified)

Measured with an explicit same-version Agent passed as dispatcher, which isolates each copy's client from the global-dispatcher slots:

undici request() + Node global FormData
6.27.0 works (duck-typed end to end)
7.0.0, 7.16.0, 7.18.0, 7.19.0 TypeError: Cannot read properties of null (reading 'byteLength') (loud, but already a regression vs v6)
7.20.0 through 7.28.0 silent hang
8.7.0 silent hang

So the cross-realm FormData support regressed at 7.0.0, and 7.20.0 turned the loud error into the silent hang. The .2 slot plus Dispatcher1Wrapper mirroring in lib/global.js landed at 7.27.0.

Verified on Node 24.18.0 and 26.4.0. Repro 2 (bare require poisoning a v6 client) verified with 7.27.1, 7.27.2, 7.28.0 and 8.7.0 as the claimer, in both require orders.

Caveat for reproducing on Node >= 26: touching the FormData/fetch globals loads Node's bundled undici 8.x, which claims both global dispatcher symbols; for npm copies older than 7.27.0 this silently reroutes their global-dispatcher requests through Node's bundled client (which brand-owns the global FormData) and masks the bug with false OK results. Use an explicit dispatcher: new Agent() from the copy under test.

Relationship to existing issues

Suggested fix directions

  1. Make extractBody's FormData branch accept what isFormDataLike accepts: the multipart encoder already only uses the public entry iterator (for (const [name, value] of object)) and Blob duck-typing (name, type, size, stream()), so encoding a foreign FormData should produce identical output. (The private #boundary getter is the only own-state dependency and can fall back to generating a boundary locally.)
  2. Alternatively, use the brand check at the client-h1 gate too, and throw InvalidArgumentError('body must be ...') like every other unsupported body type: a loud error instead of a silent hang.
  3. Independently: reconsider the eager legacy-symbol claim at require time (as proposed in Importing undici v8 silently breaks Node's bundled fetch via the legacy globalDispatcher bridge (v8's stricter core Request rejects opts the bundled v6 core accepts — e.g. request-set Content-Length) #5500), since it converts every intra-copy strictness change into a cross-copy breakage for code that never imports the new version.

Happy to turn either fix direction into a PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions