diff --git a/src/backend/util/nodeFetchDispatcherGuard.ts b/src/backend/util/nodeFetchDispatcherGuard.ts index 8b0fc9f54..614677377 100644 --- a/src/backend/util/nodeFetchDispatcherGuard.ts +++ b/src/backend/util/nodeFetchDispatcherGuard.ts @@ -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; 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 ( diff --git a/src/backend/util/secureHttp.test.ts b/src/backend/util/secureHttp.test.ts index ca5b3c24b..741cb6013 100644 --- a/src/backend/util/secureHttp.test.ts +++ b/src/backend/util/secureHttp.test.ts @@ -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; + 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', () => {