mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-25 16:21:54 +00:00
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:
1 parent
b3c85c86e9
commit
c8424f6775
2 files changed
+98
-4
No files matched your search
@@ -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 (
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user