Skip to content

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

Description

@jeswr

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:

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:

  1. 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).

  2. 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.

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