fix(fetch): raise HTTP/2 receive windows for plain fetch (#4296)

Node 26's bundled undici negotiates HTTP/2 by default with a 256 KB
per-stream receive window, which capped large downloads at about
window / RTT (8.6 MB/s vs 46 MB/s over HTTP/1.1 at 23 ms). The guard swaps
in an Agent of the same class with 4 MB stream / 16 MB connection windows.
This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-10-21 13:54:31 -07:00
1 parent b3c85c86e9
commit c8424f6775
2 files changed
+98 -4

No files matched your search

+30 -4
View File
@@ -37,10 +37,9 @@
//
// Newer undici reads the `.2` slot and older undici the `.1` slot, and both
// copies can share the process, so every present slot gets wrapped.
const DISPATCHER_SLOTS = [
Symbol.for('undici.globalDispatcher.2'),
Symbol.for('undici.globalDispatcher.1'),
];
const PRIMARY_SLOT = Symbol.for('undici.globalDispatcher.2');
const LEGACY_SLOT = Symbol.for('undici.globalDispatcher.1');
const DISPATCHER_SLOTS = [PRIMARY_SLOT, LEGACY_SLOT];
const globalSlots = globalThis as unknown as Record<symbol, unknown>;
void fetch('data:text/plain,dispatcher-guard').catch(() => {
@@ -85,6 +84,33 @@ const withOutboundTimeouts =
handler,
);
// Newer undici negotiates HTTP/2, whose 256 KB default per-stream receive
// window caps a download at about window / RTT. That undici keeps a wrapper of
// its Agent in the legacy slot, so swap in a same-class Agent with larger
// windows and re-wrap it there.
const H2_WINDOWS = {
initialWindowSize: 4 * 1024 * 1024,
connectionWindowSize: 16 * 1024 * 1024,
};
type DispatcherClass = new (options: unknown) => ComposableDispatcher;
const primary = globalSlots[PRIMARY_SLOT] as ComposableDispatcher | undefined;
const legacy = globalSlots[LEGACY_SLOT] as ComposableDispatcher | undefined;
if (
primary &&
legacy &&
primary !== legacy &&
primary.constructor.name === 'Agent' &&
!wrappedDispatchers.has(primary)
) {
const agent = new (primary.constructor as DispatcherClass)({
h2Options: H2_WINDOWS,
});
globalSlots[PRIMARY_SLOT] = agent;
globalSlots[LEGACY_SLOT] = new (legacy.constructor as DispatcherClass)(
agent,
);
}
for (const slot of DISPATCHER_SLOTS) {
const claimed = globalSlots[slot] as ComposableDispatcher | undefined;
if (
+68
View File
@@ -165,6 +165,74 @@ describe('global fetch dispatcher isolation', () => {
server.close();
}
});
describe('HTTP/2 receive windows', () => {
// Stand-ins shaped like undici's: compose() returns a wrapper that
// records what it wraps.
class Agent {
options: unknown;
constructor(options?: unknown) {
this.options = options;
}
compose() {
return { wraps: this };
}
}
class Dispatcher1Wrapper {
agent: Agent;
constructor(agent: Agent) {
this.agent = agent;
}
compose() {
return { wraps: this };
}
}
const globalSlots = globalThis as unknown as Record<symbol, unknown>;
const PRIMARY = Symbol.for('undici.globalDispatcher.2');
const LEGACY = Symbol.for('undici.globalDispatcher.1');
const runGuardWith = async (primary: unknown, legacy: unknown) => {
const before = [globalSlots[PRIMARY], globalSlots[LEGACY]];
globalSlots[PRIMARY] = primary;
globalSlots[LEGACY] = legacy;
try {
vi.resetModules();
await import('./nodeFetchDispatcherGuard.js');
return {
primary: (globalSlots[PRIMARY] as { wraps: unknown }).wraps,
legacy: (globalSlots[LEGACY] as { wraps: unknown }).wraps,
};
} finally {
[globalSlots[PRIMARY], globalSlots[LEGACY]] = before;
}
};
it('swaps in an Agent with 4 MB stream / 16 MB connection windows', async () => {
const original = new Agent();
const slots = await runGuardWith(
original,
new Dispatcher1Wrapper(original),
);
expect(slots.primary).toBeInstanceOf(Agent);
expect(slots.primary).not.toBe(original);
expect((slots.primary as Agent).options).toEqual({
h2Options: {
initialWindowSize: 4 * 1024 * 1024,
connectionWindowSize: 16 * 1024 * 1024,
},
});
expect(slots.legacy).toBeInstanceOf(Dispatcher1Wrapper);
expect((slots.legacy as Dispatcher1Wrapper).agent).toBe(
slots.primary,
);
});
it('keeps a dispatcher both slots share', async () => {
const shared = new Agent();
const slots = await runGuardWith(shared, shared);
expect(slots.primary).toBe(shared);
expect(slots.legacy).toBe(shared);
});
});
});
describe('secureHttp URL validation', () => {