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
This issue was opened by @jeswr's AI agent as part of an ecosystem performance/correctness investigation. It is agent-generated and DRAFT. @jeswr will review and finalize it, and will tag the maintainers when it is ready — please treat it as a starting point rather than a polished report. Filing now so the reproduction and root-cause analysis aren't lost.
Bug Description
Merely require('undici') (v8) in a process running on Node 22 silently re-routes Node's bundledfetch() through undici v8's dispatcher via the legacy global-dispatcher symbol. Because v8's core Request validation is stricter than the bundled undici v6 core, requests that the bundled fetch produces and that worked before the import now throwTypeError: fetch failed caused by InvalidArgumentError: invalid content-length header.
The consumer never called any undici v8 API — a bare require('undici') anywhere in the dependency tree is enough — and there is no diagnostic pointing at the import as the cause.
The concrete, common trigger is a request that carries a request-set Content-Length header (e.g. fetch-sparql-endpoint does this on every SPARQL UPDATE/query — SparqlEndpointFetcher.jsheaders.append('Content-Length', …)). Any Comunica/RDF/SPARQL app that pulls undici@8 into the same process as its SPARQL client breaks — which is how we hit it (Community Solid Server's SPARQL backend).
Environment
Node.js
22.23.1
bundled undici (backs global fetch)
6.27.0
installed undici (from require('undici'))
8.6.0
OS
Linux x64
Reproducible By
Self-contained, no server dependencies beyond loopback. Only undici@8 is installed; the script only ever calls the bundled global fetch, never undici v8's fetch.
mkdir undici-repro && cd undici-repro && npm init -y && npm i undici@8
# save repro.js (below), then:
node repro.js
repro.js
'use strict';// Minimal reproduction: requiring undici@8 flips the legacy global-dispatcher// symbol and breaks Node 22's BUNDLED fetch. One child process per scenario so// require-order is deterministic; each exercises ONLY the bundled global fetch.const{ fork }=require('node:child_process');consthttp=require('node:http');constUNDICI8=require.resolve('undici');asyncfunctionrunChild(scenario){constserver=http.createServer((req,res)=>{letn=0;req.on('data',(c)=>{n+=c.length;});req.on('end',()=>{res.writeHead(200,{'content-type': 'text/plain'});res.end('ok:'+n);});});awaitnewPromise((r)=>server.listen(0,'127.0.0.1',r));constbase=`http://127.0.0.1:${server.address().port}/`;conststate=()=>{consts1=globalThis[Symbol.for('undici.globalDispatcher.1')];consts2=globalThis[Symbol.for('undici.globalDispatcher.2')];return{legacy1: s1 ? s1.constructor.name : '<undefined>',v8: s2 ? s2.constructor.name : '<undefined>'};};constcauseChain=(err)=>{constc=[];lete=err;while(e){c.push(`${e.constructor&&e.constructor.name}: ${e.message}${e.code ? ' ['+e.code+']' : ''}`);e=e.cause;}returnc;};asyncfunctiontryFetch(label){constout={ label,plainGet: null,fseUpdate: null};try{constr=awaitfetch(base);out.plainGet={ok: true,status: r.status,body: awaitr.text()};}catch(err){out.plainGet={ok: false,cause: causeChain(err)};}try{// The shape fetch-sparql-endpoint issues: URLSearchParams body + a// request-set Content-Length header.constheaders=newHeaders();headers.append('Content-Type','application/x-www-form-urlencoded');constbody=newURLSearchParams();body.set('update','INSERT DATA { <a:b> <a:c> "d" }');headers.append('Content-Length',body.toString().length.toString());constr=awaitfetch(base,{method: 'POST', headers, body });out.fseUpdate={ok: true,status: r.status,body: awaitr.text()};}catch(err){out.fseUpdate={ok: false,cause: causeChain(err)};}returnout;}constresult={ scenario,before: null,after: null,fetches: []};if(scenario==='control'){result.before=state();result.fetches.push(awaittryFetch('undici@8 NOT required'));result.after=state();}elseif(scenario==='require-before-fetch'){result.before=state();require(UNDICI8);result.after=state();result.fetches.push(awaittryFetch('AFTER require(undici@8)'));}elseif(scenario==='require-after-fetch'){result.before=state();result.fetches.push(awaittryFetch('BEFORE require(undici@8)'));require(UNDICI8);result.after=state();result.fetches.push(awaittryFetch('AFTER require(undici@8)'));}server.close();process.stdout.write('__R__'+JSON.stringify(result)+'\n');}functionspawn(scenario){returnnewPromise((resolve,reject)=>{constchild=fork(__filename,['--child',scenario],{stdio: ['ignore','pipe','inherit','ipc']});letbuf='';child.stdout.on('data',(d)=>{buf+=d;});child.on('exit',(code)=>{constl=buf.split('\n').find((x)=>x.startsWith('__R__'));l ? resolve(JSON.parse(l.slice(5))) : reject(newError('no result, exit '+code+'\n'+buf));});});}constfmt=(f)=>{constone=(n,r)=>!r ? ` ${n}: (skipped)` : r.ok ? ` ${n}: OK (status ${r.status})` : ` ${n}: FAIL\n${r.cause.map((c)=>' -> '+c).join('\n')}`;return` [${f.label}]\n${one('plain GET ',f.plainGet)}\n${one('fse-shape UPDATE',f.fseUpdate)}`;};asyncfunctionmain(){console.log(`Node ${process.versions.node} | bundled undici ${process.versions.undici} | installed undici ${require('./node_modules/undici/package.json').version}\n`);for(constsof['control','require-before-fetch','require-after-fetch']){constr=awaitspawn(s);console.log('-'.repeat(70));console.log(`SCENARIO: ${s}`);console.log(` legacy symbol.1: ${r.before.legacy1} -> ${r.after.legacy1} (v8 symbol.2: ${r.after.v8})`);for(constfofr.fetches)console.log(fmt(f));}}consti=process.argv.indexOf('--child');if(i!==-1)runChild(process.argv[i+1]).catch((e)=>{console.error(e);process.exit(1);});elsemain().catch((e)=>{console.error(e);process.exit(1);});
Observed output
Node 22.23.1 | bundled undici 6.27.0 | installed undici 8.6.0
----------------------------------------------------------------------
SCENARIO: control
legacy symbol.1: <undefined> -> Agent (v8 symbol.2: <undefined>)
[undici@8 NOT required]
plain GET : OK (status 200)
fse-shape UPDATE: OK (status 200)
----------------------------------------------------------------------
SCENARIO: require-before-fetch
legacy symbol.1: <undefined> -> Dispatcher1Wrapper (v8 symbol.2: Agent)
[AFTER require(undici@8)]
plain GET : OK (status 200)
fse-shape UPDATE: FAIL
-> TypeError: fetch failed
-> InvalidArgumentError: invalid content-length header [UND_ERR_INVALID_ARG]
----------------------------------------------------------------------
SCENARIO: require-after-fetch
legacy symbol.1: <undefined> -> Dispatcher1Wrapper (v8 symbol.2: Agent)
[BEFORE require(undici@8)]
plain GET : OK (status 200)
fse-shape UPDATE: OK (status 200) <- worked before the require
[AFTER require(undici@8)]
plain GET : OK (status 200)
fse-shape UPDATE: FAIL <- same call, now broken
-> TypeError: fetch failed
-> InvalidArgumentError: invalid content-length header [UND_ERR_INVALID_ARG]
Both require orders reproduce. The control run proves the request is well-formed for the bundled v6 core (status 200). The require-after-fetch run proves the import poisons already-working code — the bundled fetch re-reads the legacy symbol on every call.
Scope, stated honestly: this is not "every fetch" — a plain GET keeps working. It breaks requests whose options the bundled-v6 core tolerated but the v8 core rejects. The concrete, in-the-wild instance below (request-set Content-Length) is common enough to take down whole SPARQL/RDF stacks, but the general defect is the silent cross-version transport swap, not this one header.
Root cause
1. require('undici') (v8) eagerly claims the legacy .1 symbol.lib/global.js runs at load time:
// lib/global.js:5-13constglobalDispatcher=Symbol.for('undici.globalDispatcher.2')constlegacyGlobalDispatcher=Symbol.for('undici.globalDispatcher.1')...if(getGlobalDispatcher()===undefined){// reads .2, which is undefinedsetGlobalDispatcher(newAgent())// -> also defines .1 (below)}
2. Node 22's bundled fetch (undici v6) reads that .1 symbol to find its global dispatcher (verified: installing a spy under Symbol.for('undici.globalDispatcher.1') intercepts the bundled fetch's dispatch). So after the import, bundled fetch dispatches into v8's Dispatcher1Wrapper → v8 Agent → v8 core Request.
3. The bridge forwards request options unchanged.Dispatcher1Wrapper.dispatch only adjusts allowH2; it passes opts (including opts.headers) straight into the v8 dispatcher. It adapts the handler protocol but not the request-options validation semantics.
4. v8's core Request validates content-length more strictly than v6. For the request-set Content-Length, the bundled fetch hands the dispatcher a comma-combined value (captured directly off the bundled fetch's dispatch opts):
opts.headers["content-length"] === "25, 25"
(the request-supplied Content-Length: 25 combined with fetch's own auto-computed 25). v6's core normalizes this with parseInt(val,10) + Number.isFinite and accepts it. v8's core requires a digit-only string:
So a value that was valid under the transport the consumer was actually using (bundled v6) is rejected by the transport it was silently switched to (v8).
This stricter check is itself correct and intentional (#5059 / #5060). The bug is that the eager legacy-symbol claim subjects the bundled fetch to it without consent or diagnostics.
Why this is the same class of bug the bridge has already been patched for
The Dispatcher1Wrapper bridge (added in #4827) exists precisely so setGlobalDispatcher() and Node's bundled fetch can share v8's dispatcher (#4962). It has already needed targeted fixes where v6/v8 semantics diverge across the bridge:
This report is the next instance of the same class, on the request-header-validation axis, which neither fix covers.
Impact
Silent, total breakage of any consumer whose dependency tree pulls undici@8 into the same process as code that uses Node's bundled fetch with request options the v8 core rejects — most commonly a request-set Content-Length (e.g. fetch-sparql-endpoint, and thus Comunica / Community Solid Server SPARQL backends, and any app that sets its own Content-Length).
No opt-in and no signal: a bare require('undici') — possibly transitive, possibly for an unrelated feature — is enough; the surfaced error (fetch failed / invalid content-length header) points at the victim's request, not at the import.
Correctness, not perf: the request never leaves the process.
Two suggested fix directions (maintainer design call)
We believe the right resolution is a design decision for the undici maintainers, so we are filing an issue rather than a PR. Two directions:
Reconcile request options in the legacy bridge. Since Dispatcher1Wrapper.dispatch already massages opts for legacy consumers (allowH2), extend it to normalize the small set of request-header values whose v6-vs-v8 validation strictness diverges — at minimum collapse/repair a bundled-fetch-produced content-length before it reaches the v8 core (or, more conservatively, surface a clearer error identifying the import as the cause). Trade-off: deciding which normalizations are safe vs. which should still hard-fail (a genuinely conflicting "25, 30" arguably should be rejected).
Don't claim the legacy .1 symbol eagerly / make it opt-in. Only mirror v8's dispatcher onto Symbol.for('undici.globalDispatcher.1') when the app explicitly calls setGlobalDispatcher() (or via an explicit opt-in), instead of at import time from the default-Agent bootstrap. Then a bare require('undici') for unrelated APIs leaves Node's bundled fetch on its own bundled dispatcher. Trade-off: this partially walks back fix: mirror the legacy global dispatcher for built-in fetch #4962's intent that setGlobalDispatcher() affect the bundled fetch on all Node versions — so the opt-in boundary needs care.
Happy to turn either direction into a PR with regression tests once the maintainers indicate a preferred approach.
Filed by @jeswr's AI agent; @jeswr to review/finalize and tag maintainers when ready. Reproduction and analysis performed on undici 8.6.0 + Node 22.23.1.
Bug Description
Merely
require('undici')(v8) in a process running on Node 22 silently re-routes Node's bundledfetch()through undici v8's dispatcher via the legacy global-dispatcher symbol. Because v8's coreRequestvalidation is stricter than the bundled undici v6 core, requests that the bundledfetchproduces and that worked before the import now throwTypeError: fetch failedcaused byInvalidArgumentError: invalid content-length header.The consumer never called any undici v8 API — a bare
require('undici')anywhere in the dependency tree is enough — and there is no diagnostic pointing at the import as the cause.The concrete, common trigger is a request that carries a request-set
Content-Lengthheader (e.g.fetch-sparql-endpointdoes this on every SPARQL UPDATE/query —SparqlEndpointFetcher.jsheaders.append('Content-Length', …)). Any Comunica/RDF/SPARQL app that pullsundici@8into the same process as its SPARQL client breaks — which is how we hit it (Community Solid Server's SPARQL backend).Environment
22.23.1fetch)6.27.0require('undici'))8.6.0Reproducible By
Self-contained, no server dependencies beyond loopback. Only
undici@8is installed; the script only ever calls the bundled globalfetch, never undici v8'sfetch.repro.jsObserved output
Both require orders reproduce. The
controlrun proves the request is well-formed for the bundled v6 core (status 200). Therequire-after-fetchrun proves the import poisons already-working code — the bundled fetch re-reads the legacy symbol on every call.Root cause
1.
require('undici')(v8) eagerly claims the legacy.1symbol.lib/global.jsruns at load time:2. Node 22's bundled
fetch(undici v6) reads that.1symbol to find its global dispatcher (verified: installing a spy underSymbol.for('undici.globalDispatcher.1')intercepts the bundled fetch's dispatch). So after the import, bundled fetch dispatches into v8'sDispatcher1Wrapper→ v8Agent→ v8 coreRequest.3. The bridge forwards request options unchanged.
Dispatcher1Wrapper.dispatchonly adjustsallowH2; it passesopts(includingopts.headers) straight into the v8 dispatcher. It adapts the handler protocol but not the request-options validation semantics.4. v8's core
Requestvalidatescontent-lengthmore strictly than v6. For the request-setContent-Length, the bundled fetch hands the dispatcher a comma-combined value (captured directly off the bundled fetch's dispatch opts):(the request-supplied
Content-Length: 25combined with fetch's own auto-computed25). v6's core normalizes this withparseInt(val,10)+Number.isFiniteand accepts it. v8's core requires a digit-only string:So a value that was valid under the transport the consumer was actually using (bundled v6) is rejected by the transport it was silently switched to (v8).
This stricter check is itself correct and intentional (#5059 / #5060). The bug is that the eager legacy-symbol claim subjects the bundled fetch to it without consent or diagnostics.
Why this is the same class of bug the bridge has already been patched for
The
Dispatcher1Wrapperbridge (added in #4827) exists precisely sosetGlobalDispatcher()and Node's bundled fetch can share v8's dispatcher (#4962). It has already needed targeted fixes where v6/v8 semantics diverge across the bridge:Dispatcher1Wrapper.dispatchforceallowH2: falsefor legacy consumers (the exactoptsmassaging at dispatcher1-wrapper.js:91-92).Agent#5341 / fix: preserve raw headers in dispatch handler bridge #5345 — response-headers divergence: v8 fetch + v7Agentdropped response headers; fixed by preservingrawHeadersin the bridge.This report is the next instance of the same class, on the request-header-validation axis, which neither fix covers.
Impact
undici@8into the same process as code that uses Node's bundledfetchwith request options the v8 core rejects — most commonly a request-setContent-Length(e.g.fetch-sparql-endpoint, and thus Comunica / Community Solid Server SPARQL backends, and any app that sets its ownContent-Length).require('undici')— possibly transitive, possibly for an unrelated feature — is enough; the surfaced error (fetch failed/invalid content-length header) points at the victim's request, not at the import.Two suggested fix directions (maintainer design call)
We believe the right resolution is a design decision for the undici maintainers, so we are filing an issue rather than a PR. Two directions:
Reconcile request options in the legacy bridge. Since
Dispatcher1Wrapper.dispatchalready massagesoptsfor legacy consumers (allowH2), extend it to normalize the small set of request-header values whose v6-vs-v8 validation strictness diverges — at minimum collapse/repair a bundled-fetch-producedcontent-lengthbefore it reaches the v8 core (or, more conservatively, surface a clearer error identifying the import as the cause). Trade-off: deciding which normalizations are safe vs. which should still hard-fail (a genuinely conflicting"25, 30"arguably should be rejected).Don't claim the legacy
.1symbol eagerly / make it opt-in. Only mirror v8's dispatcher ontoSymbol.for('undici.globalDispatcher.1')when the app explicitly callssetGlobalDispatcher()(or via an explicit opt-in), instead of at import time from the default-Agent bootstrap. Then a barerequire('undici')for unrelated APIs leaves Node's bundled fetch on its own bundled dispatcher. Trade-off: this partially walks back fix: mirror the legacy global dispatcher for built-in fetch #4962's intent thatsetGlobalDispatcher()affect the bundled fetch on all Node versions — so the opt-in boundary needs care.Happy to turn either direction into a PR with regression tests once the maintainers indicate a preferred approach.
Filed by @jeswr's AI agent; @jeswr to review/finalize and tag maintainers when ready. Reproduction and analysis performed on undici 8.6.0 + Node 22.23.1.