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)
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 7.7k
- Forks
- 879
- Avg merge
- 2d 16h
- Merged PRs (30d)
- 68
Description
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 bundled fetch() 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 throw TypeError: 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.js headers.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');
const http = require('node:http');
const UNDICI8 = require.resolve('undici');
async function runChild(scenario) {
const server = http.createServer((req, res) => {
let n = 0; req.on('data', (c) => { n += c.length; });
req.on('end', () => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok:' + n); });
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}/`;
const state = () => {
const s1 = globalThis[Symbol.for('undici.globalDispatcher.1')];
const s2 = globalThis[Symbol.for('undici.globalDispatcher.2')];
return { legacy1: s1 ? s1.constructor.name : '<undefined>', v8: s2 ? s2.constructor.name : '<undefined>' };
};
const causeChain = (err) => { const c = []; let e = err; while (e) { c.push(`${e.constructor && e.constructor.name}: ${e.message}${e.code ? ' [' + e.code + ']' : ''}`); e = e.cause; } return c; };
async function tryFetch(label) {
const out = { label, plainGet: null, fseUpdate: null };
try { const r = await fetch(base); out.plainGet = { ok: true, status: r.status, body: await r.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.
const headers = new Headers();
headers.append('Content-Type', 'application/x-www-form-urlencoded');
const body = new URLSearchParams(); body.set('update', 'INSERT DATA { <a:b> <a:c> "d" }');
headers.append('Content-Length', body.toString().length.toString());
const r = await fetch(base, { method: 'POST', headers, body });
out.fseUpdate = { ok: true, status: r.status, body: await r.text() };
} catch (err) { out.fseUpdate = { ok: false, cause: causeChain(err) }; }
return out;
}
const result = { scenario, before: null, after: null, fetches: [] };
if (scenario === 'control') {
result.before = state(); result.fetches.push(await tryFetch('undici@8 NOT required')); result.after = state();
} else if (scenario === 'require-before-fetch') {
result.before = state(); require(UNDICI8); result.after = state();
result.fetches.push(await tryFetch('AFTER require(undici@8)'));
} else if (scenario === 'require-after-fetch') {
result.before = state();
result.fetches.push(await tryFetch('BEFORE require(undici@8)'));
require(UNDICI8); result.after = state();
result.fetches.push(await tryFetch('AFTER require(undici@8)'));
}
server.close();
process.stdout.write('__R__' + JSON.stringify(result) + '\n');
}
function spawn(scenario) {
return new Promise((resolve, reject) => {
const child = fork(__filename, ['--child', scenario], { stdio: ['ignore', 'pipe', 'inherit', 'ipc'] });
let buf = ''; child.stdout.on('data', (d) => { buf += d; });
child.on('exit', (code) => { const l = buf.split('\n').find((x) => x.startsWith('__R__')); l ? resolve(JSON.parse(l.slice(5))) : reject(new Error('no result, exit ' + code + '\n' + buf)); });
});
}
const fmt = (f) => {
const one = (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)}`;
};
async function main() {
console.log(`Node ${process.versions.node} | bundled undici ${process.versions.undici} | installed undici ${require('./node_modules/undici/package.json').version}\n`);
for (const s of ['control', 'require-before-fetch', 'require-after-fetch']) {
const r = await spawn(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 (const f of r.fetches) console.log(fmt(f));
}
}
const i = process.argv.indexOf('--child');
if (i !== -1) runChild(process.argv[i + 1]).catch((e) => { console.error(e); process.exit(1); });
else main().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-13
const globalDispatcher = Symbol.for('undici.globalDispatcher.2')
const legacyGlobalDispatcher = Symbol.for('undici.globalDispatcher.1')
...
if (getGlobalDispatcher() === undefined) { // reads .2, which is undefined
setGlobalDispatcher(new Agent()) // -> also defines .1 (below)
}
// lib/global.js:27-34 — setGlobalDispatcher always (re)defines the LEGACY symbol
const legacyAgent = agent instanceof Dispatcher1Wrapper ? agent : new Dispatcher1Wrapper(agent)
Object.defineProperty(globalThis, legacyGlobalDispatcher, { value: legacyAgent, ... })
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:
// lib/core/request.js:31-44
function isValidContentLengthHeaderValue (val) {
if (typeof val !== 'string' || val.length === 0) return false
for (let i = 0; i < val.length; i++) {
const c = val.charCodeAt(i)
if (c < 48 || c > 57) return false // the ',' (44) fails here
}
return true
}
// lib/core/request.js:498-505
} else if (headerName === 'content-length') {
...
if (!isValidContentLengthHeaderValue(val)) {
throw new InvalidArgumentError('invalid content-length header') // <-- thrown
}
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:
- #4989 / #4990 — request-opts divergence: bundled fetch's H2 negotiation broke; fixed by having
Dispatcher1Wrapper.dispatchforceallowH2: falsefor legacy consumers (the exactoptsmassaging at dispatcher1-wrapper.js:91-92). - #5341 / #5345 — response-headers divergence: v8 fetch + v7
Agentdropped 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
- Silent, total breakage of any consumer whose dependency tree pulls
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). - 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.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 #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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Run the supplied repro.js first to confirm the bundled fetch changes after requiring undici. Then read lib/global.js, dispatcher1-wrapper.js, and lib/core/request.js, tracing the legacy dispatcher symbols and request-option validation. Done should prevent an undici import from silently changing bundled fetch behavior while preserving the intended dispatcher bridge.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, node.js
- Domain
- networking
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 38/100