You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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):
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.
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.0consthttp=require('node:http');const{ request }=require('undici');constserver=http.createServer((req,res)=>{req.on('data',()=>{});req.on('end',()=>res.end('{}'));});server.listen(0,async()=>{constform=newFormData();// Node's global FormDataform.append('file',newBlob(['hello world'],{type: 'text/plain'}),'hello.txt');constres=awaitrequest(`http://localhost:${server.address().port}/`,{method: 'POST',body: form,});// <- never resolves, never rejectsconsole.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.0consthttp=require('node:http');require('undici8');// bare require, never used again; remove this line and everything worksconst{ request }=require('undici6');constserver=http.createServer((req,res)=>{req.on('data',()=>{});req.on('end',()=>res.end('{}'));});server.listen(0,async()=>{constform=newFormData();form.append('file',newBlob(['hello world'],{type: 'text/plain'}),'hello.txt');constres=awaitrequest(`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.jsextractBody() 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');constfd=newFormData();// Node's globalfd.append('file',newBlob(['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
fetch() silently stringifies native globalThis.FormData body as "[object FormData]" (regression in v8.0.2) #5016 (fetch() stringifies native FormData) was closed as expected behavior. This report is different on two counts: request()'s own gate accepts the object before the extraction drops it, and the failure mode is an unbounded silent hang, not a wrong-but-fast request. And in the repro-2 shape, the code hitting the bug is undici 6, where cross-realm FormData is supported; "use this copy's FormData" is not actionable for a v6 client that got its dispatcher swapped out from under it by a bare require elsewhere in the tree.
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.)
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.
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 insideextractBodythen 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):
request(url, { body: formData })whereformDatais Node'sglobalThis.FormData(the most common way user code constructs one) hangs. undici's ownFormDataworks.require('undici')anywhere in the process capturesSymbol.for('undici.globalDispatcher.1')(every major does guarded eager init at require time; since 7.27.0 aDispatcher1Wrapperis mirrored into the legacy slot, and v8's init guard only checks its own.2slot, so it overwrites.1even 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 abortedafter @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 barerequirein an unrelated module.Reproduction 1: standalone (no version mixing)
Replace
new FormData()withnew (require('undici').FormData)()and it completes with a correct multipart body.Reproduction 2: bare require of v8 breaks an undici v6 client
diagnostics_channelshowsundici:request:createandundici:client:sendHeadersfiring, butundici:request:bodySentnever does. Despite thesendHeadersevent, 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.jsroutes the body into form-data extraction based on the duck-typed checkutil.isFormDataLike(body)(structural: append/get/set methods plusSymbol.toStringTag === 'FormData'), which accepts FormData from any realm: Node's builtin, another undici copy, a polyfill.lib/web/fetch/body.jsextractBody()gates the actual multipart encoding on the brand checkwebidl.is.FormData(object), which only accepts instances of this copy's ownFormDataclass.For a cross-realm FormData the duck check passes and the brand check fails, so
extractBodyfalls through every branch and returns an empty descriptor. This is observable directly:client-h1then queues the headers and waits on that never-producing stream withcontentLength = null: nothing reaches the wire, nothing errors.Affected versions (verified)
Measured with an explicit same-version
Agentpassed asdispatcher, which isolates each copy's client from the global-dispatcher slots:request()+ Node global FormDataTypeError: Cannot read properties of null (reading 'byteLength')(loud, but already a regression vs v6)So the cross-realm FormData support regressed at 7.0.0, and 7.20.0 turned the loud error into the silent hang. The
.2slot plusDispatcher1Wrappermirroring inlib/global.jslanded 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 explicitdispatcher: new Agent()from the copy under test.Relationship to existing issues
request()'s own gate accepts the object before the extraction drops it, and the failure mode is an unbounded silent hang, not a wrong-but-fast request. And in the repro-2 shape, the code hitting the bug is undici 6, where cross-realm FormData is supported; "use this copy's FormData" is not actionable for a v6 client that got its dispatcher swapped out from under it by a bare require elsewhere in the tree.Dispatcher1Wrapper; the FormData hang is unaffected by fix: collapse identical repeated content-length in legacy dispatcher bridge #5502.Requestcannot parseFormDatafrom Undici v5 #4285 (v7 Request cannot parse v5 FormData) is the fetch-side sibling of the same cross-realm brand-check problem.Suggested fix directions
extractBody's FormData branch accept whatisFormDataLikeaccepts: 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#boundarygetter is the only own-state dependency and can fall back to generating a boundary locally.)client-h1gate too, and throwInvalidArgumentError('body must be ...')like every other unsupported body type: a loud error instead of a silent hang.Happy to turn either fix direction into a PR.