From 3701f266817515949e74de5a09bfb3f38974810b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 18:53:23 -0700 Subject: [PATCH 1/5] Lower Headers, Request and Response members that undici-types declares as properties @types/node's fetch declarations (undici-types) spell the Headers methods and the body mixin as function-typed properties (readonly append: (...) => void), where scriptc's own declarations use methods. The receiver call lowering treated such a standard-library property as a stored callback: Headers.forEach was refused at build time and append/get/set/... compiled to a runtime 'is not a function'. Standard-library members now take the shared receiver dispatch either way. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-calls.ts | 12 +++++++ .../node-types/fetch-handle-members.ts.json | 6 ++++ .../node-types/fetch-handle-members.ts | 32 +++++++++++++++++++ tests/harness/project-config.test.ts | 21 ++++++++++++ 4 files changed, 71 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fetch-handle-members.ts.json create mode 100644 tests/fixtures/node-types/fetch-handle-members.ts diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 5a48387825..464499ba96 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -3665,8 +3665,20 @@ function lowerDynReceiverMethodCall( ) ) return true; + // A standard-library member spelled as a function-typed property + // (undici-types declares Headers.append/forEach/... and the body mixin + // as `readonly append: (...) => void`; scriptc's own declarations use + // methods) is still the handle's native method: it takes the shared + // receiver dispatch below either way (which also calls a plain object's + // own member), so the lowering does not depend on which declaration + // file supplies the type. + const libMethod = + prop !== undefined && + prop.declarations.length > 0 && + prop.declarations.every((decl) => lowerer.isStdlibFile(decl.getSourceFile())); return ( prop !== undefined && + !libMethod && prop.declarations.some( (decl) => ts.isPropertyDeclaration(decl) || ts.isPropertySignature(decl), ) diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fetch-handle-members.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fetch-handle-members.ts.json new file mode 100644 index 0000000000..80464768ac --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fetch-handle-members.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/fetch-handle-members.ts" + ], + "diags": [] +} diff --git a/tests/fixtures/node-types/fetch-handle-members.ts b/tests/fixtures/node-types/fetch-handle-members.ts new file mode 100644 index 0000000000..07f8f689ef --- /dev/null +++ b/tests/fixtures/node-types/fetch-handle-members.ts @@ -0,0 +1,32 @@ +// undici-types (@types/node's fetch declarations) spells the Headers methods +// and the Request/Response body mixin as function-typed properties; they +// must lower to the same native handle methods as the shipped declarations. +async function main(): Promise { + const h = new Headers([["b", "2"], ["a", "1"]]); + h.append("set-cookie", "x=1"); + h.append("set-cookie", "y=2"); + h.set("c", "3"); + h.delete("b"); + const seen: string[] = []; + h.forEach((value, name) => seen.push(`${name}=${value}`)); + console.log(seen.join(","), h.get("a"), h.has("b"), h.getSetCookie().join("|")); + const r1 = new Response("body-text", { status: 202, statusText: "Acc", headers: h }); + console.log(r1.status, r1.statusText, r1.ok, r1.headers.get("c"), r1.bodyUsed); + console.log(await r1.text(), r1.bodyUsed); + const r2 = new Response(JSON.stringify({ k: 1 })); + console.log(JSON.stringify(await r2.json())); + const r3 = new Response(new Uint8Array([1, 2, 3])); + console.log((await r3.arrayBuffer()).byteLength); + const r4 = new Response("abc"); + console.log((await r4.bytes()).length); + const r5 = Response.json({ z: true }, { status: 200 }); + console.log(r5.headers.get("content-type"), await r5.text()); + const req = new Request("http://x.test/a?b=1", { method: "POST", body: "payload", headers: { q: "1" } }); + console.log(req.method, req.url, req.headers.get("q"), await req.text()); + const req2 = new Request("http://x.test/j", { method: "PUT", body: "{\"n\":2}" }); + console.log(JSON.stringify(await req2.json())); + const ac = new AbortController(); + ac.abort(); + console.log(ac.signal.aborted); +} +main(); diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index 802fbd3e19..d98f12da37 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -52,6 +52,27 @@ test("node-types: filesystem capacity supports typed number and BigInt results u expect(comparableStderr(native.stderr)).toBe(node.stderr); }); +test("node-types: Headers, Request and Response members typed by undici-types run natively", async () => { + const outDir = outDirFor("fetch-handle-members"); + const entry = join(nodeTypesDir, "fetch-handle-members.ts"); + const result = await compile(entry, { + outPath: join( + outDir, + process.platform === "win32" ? "fetch-handle-members.exe" : "fetch-handle-members", + ), + outDir, + sanitize, + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const [native, node] = await Promise.all([ + execFileAsync(result.binaryPath), + execFileAsync(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", entry]), + ]); + expect(native.stdout).toBe(node.stdout); + expect(comparableStderr(native.stderr)).toBe(node.stderr); +}); + test("node-types: http header arrays and server.address() follow @types/node's types", async () => { const outDir = outDirFor("http-headers-address"); const entry = join(nodeTypesDir, "http-headers-address.ts"); From 6e67b1da09ea5638b46f1b7b784649a9e3971883 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 18:53:23 -0700 Subject: [PATCH 2/5] Run the poll phase once per event-loop iteration The net unit drained its poller every time the loop came back to the I/O station, including the restarts that drain microtasks. I/O that became ready during the iteration's own callbacks (a loopback connect started by one of them) was delivered before the check phase, so setImmediate callbacks queued earlier ran after it; Node delivers it in the next iteration's poll phase. The poller now drains once per iteration (a full batch keeps draining), reopening after the check phase. Back-to-back undici requests now reuse connections like Node, so the fixtures drop their setImmediate turn between requests. Co-Authored-By: Claude Opus 5.5 --- .../6251-net-connect-poll-phase-order.ts.json | 6 ++ packages/runtime/src/scr_async.c | 20 ++++++ packages/runtime/src/scr_net.c | 22 +++++- .../6251-net-connect-poll-phase-order.ts | 70 +++++++++++++++++++ tests/fixtures/npm/cases/undici5/main.ts | 1 - tests/fixtures/npm/cases/undici7/main.ts | 1 - 6 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6251-net-connect-poll-phase-order.ts.json create mode 100644 tests/corpus/6251-net-connect-poll-phase-order.ts diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6251-net-connect-poll-phase-order.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6251-net-connect-poll-phase-order.ts.json new file mode 100644 index 0000000000..0d0190a322 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6251-net-connect-poll-phase-order.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6251-net-connect-poll-phase-order.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 88ad0f7bde..de48d6b616 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -4149,6 +4149,19 @@ static int scr_workers_pollfd(void) { * macrotask). */ bool scr_loop_has_ready(void) { return scr_ready_len > 0 || scr_nt_head != NULL; } +/* The poll phase runs once per loop iteration, like libuv's: the net unit + * drains the poller at most once between check phases. I/O that becomes + * ready while this iteration's callbacks run (a loopback connect started + * by one of them) waits for the next iteration, so immediates queued in + * the meantime fire first — Node's poll → check order. */ +static SCR_TL bool scr_loop_io_open = true; +bool scr_loop_io_take(void) { + bool open = scr_loop_io_open; + scr_loop_io_open = false; + return open; +} +void scr_loop_io_reopen(void) { scr_loop_io_open = true; } + /* The process.nextTick queue to exhaustion, ticks queued meanwhile * included. false: a tick threw (uncaught, pending). */ static bool scr_loop_run_ticks(void) { @@ -4618,6 +4631,7 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { * every macrotask; pending ticks or a woken fiber restart the turn * first (the timer loop's break above), so the phase only runs on a * drained checkpoint. */ + bool check_ran = false; if (scr_ready_len == 0 && scr_nt_head == NULL && scr_nunhandled == 0 && scr_pending_immediates > 0) { size_t end = scr_nimmediates; while (scr_immediates_head < end) { @@ -4648,7 +4662,13 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { scr_immediates_head = 0; scr_nimmediates = 0; } + check_ran = true; } + /* The next poll phase opens once this iteration's check phase ran (or + * had nothing to run). A check phase deferred for pending microtasks + * keeps it closed, so the next iteration reaches the check phase + * before polling again. */ + if (check_ran || scr_pending_immediates == 0) scr_loop_io_open = true; } return rejection_failed; } diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index f13906454c..9684aebe97 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -4908,17 +4908,28 @@ static int scr_net_pollfd(void) { return scr_net_poller != NULL ? scrp_poller_fd(scr_net_poller) : -1; } +bool scr_loop_io_take(void); +void scr_loop_io_reopen(void); + static void scr_net_dispatch(void) { if (!scr_net_servers && !scr_net_socks && !scr_net_proto_pending()) { return; } + /* One poller drain per loop iteration (scr_loop_io_take); a full batch + * keeps draining, like libuv's poll phase. Deferred emits (the sweep) + * still run on every call. */ + bool may_drain = scr_loop_io_take(); for (;;) { scr_net_sweep(); if (scr_exc_pending()) return; if (scr_net_poller == NULL) return; ScrPollerEvent evs[64]; - int n = scrp_drain(scr_net_poller, evs, 64); + int n = 0; + if (may_drain) { + n = scrp_drain(scr_net_poller, evs, 64); + may_drain = n == 64; + } for (int i = 0; i < n; i++) { void *udata = evs[i].udata; if (!udata) continue; @@ -4992,8 +5003,13 @@ static void scr_net_dispatch(void) { } if (scr_exc_pending()) return; } - if (!scr_net_flags_pending()) return; - if (scr_loop_has_ready()) return; /* microtasks interleave first */ + if (!scr_net_flags_pending() && !may_drain) return; + if (scr_loop_has_ready()) { + /* microtasks interleave first; a full batch's rest drains on the + * next call, still within this poll phase */ + if (may_drain) scr_loop_io_reopen(); + return; + } } } diff --git a/tests/corpus/6251-net-connect-poll-phase-order.ts b/tests/corpus/6251-net-connect-poll-phase-order.ts new file mode 100644 index 0000000000..cfe09292ae --- /dev/null +++ b/tests/corpus/6251-net-connect-poll-phase-order.ts @@ -0,0 +1,70 @@ +// A connection started by a poll-phase callback completes in the NEXT loop +// iteration's poll phase: the callback's nextTick and promise jobs run +// first, then the check phase's setImmediate, then the new socket's +// 'connect' (libuv/Node order; loopback connects complete at once, so an +// event loop that polls again before the check phase reorders them). The +// second half is the same through an awaited promise, the shape of an HTTP +// client that dials a fresh connection after the previous response. +import * as net from "node:net"; + +const log: string[] = []; +const server = net.createServer((socket) => { + socket.on("data", (chunk: Buffer) => socket.write(chunk)); +}); + +function dial(port: number, label: string): Promise { + return new Promise((resolve) => { + const socket = net.connect({ port, host: "127.0.0.1" }); + socket.on("connect", () => { + log.push(`connect ${label}`); + resolve(socket); + }); + }); +} + +function roundTrip(socket: net.Socket, label: string): Promise { + return new Promise((resolve) => { + socket.once("data", () => { + log.push(`data ${label}`); + setImmediate(() => log.push(`immediate after data ${label}`)); + process.nextTick(() => log.push(`tick after data ${label}`)); + Promise.resolve().then(() => log.push(`promise after data ${label}`)); + resolve(); + }); + socket.write("ping"); + }); +} + +server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = address !== null && typeof address === "object" ? address.port : 0; + const first = net.connect({ port, host: "127.0.0.1" }); + first.on("connect", () => { + log.push("connect first"); + first.write("ping"); + }); + first.once("data", () => { + log.push("data first"); + setImmediate(() => log.push("immediate from data")); + process.nextTick(() => log.push("tick from data")); + Promise.resolve().then(() => log.push("promise from data")); + const second = net.connect({ port, host: "127.0.0.1" }); + second.on("connect", async () => { + log.push("connect second"); + second.destroy(); + first.destroy(); + // Awaited: each response's continuation dials the next connection. + const a = await dial(port, "a"); + await roundTrip(a, "a"); + log.push("continuation after a"); + const b = await dial(port, "b"); + await roundTrip(b, "b"); + a.destroy(); + b.destroy(); + setImmediate(() => { + server.close(); + console.log(log.join("\n")); + }); + }); + }); +}); diff --git a/tests/fixtures/npm/cases/undici5/main.ts b/tests/fixtures/npm/cases/undici5/main.ts index a65601a099..b9c2eafc9e 100644 --- a/tests/fixtures/npm/cases/undici5/main.ts +++ b/tests/fixtures/npm/cases/undici5/main.ts @@ -195,7 +195,6 @@ server.listen(0, "127.0.0.1", async () => { for (let i = 0; i < 4; i++) { const r = await request(`${origin}/r${i}`, { dispatcher: agent }); await r.body.text(); - await turn(); } console.log("connections opened:", agent.connections); console.log("stats:", agent.statsFor(origin)); diff --git a/tests/fixtures/npm/cases/undici7/main.ts b/tests/fixtures/npm/cases/undici7/main.ts index 24b608e612..054175e4d6 100644 --- a/tests/fixtures/npm/cases/undici7/main.ts +++ b/tests/fixtures/npm/cases/undici7/main.ts @@ -201,7 +201,6 @@ server.listen(0, "127.0.0.1", async () => { for (let i = 0; i < 4; i++) { const r = await request(`${origin}/r${i}`, { dispatcher: agent }); await r.body.text(); - await turn(); } console.log("connections opened:", agent.connections); console.log("stats:", agent.statsFor(origin)); From 4e0e7fc1afbb114163ad1bd55c598f10db9d5a00 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 19:55:18 -0700 Subject: [PATCH 3/5] Box URL into checked-dynamic unions beside fetch handles fetch's input under @types/node is string | URL | Request. A union with a checked-dynamic arm collapses to the checked-dynamic representation only when every sibling survives the collapse; URL did not qualify, so the type (and anything typed as typeof globalThis.fetch, such as a custom fetch cast through unknown) did not compile. URL's handle boxes by reference, keeps its accessors and setters, answers instanceof URL and checks back out to the typed value, so URL arms now join the collapse. A fetch() input typed string | URL also converts through the checked-dynamic input path instead of being narrowed to a string, which threw on the URL arm. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-stdlib.ts | 22 +++++++-- packages/compiler/src/frontend/type-mapper.ts | 27 +++++++---- .../6252-fetch-input-url-unions.ts.json | 6 +++ .../node-types/fetch-input-unions.ts.json | 6 +++ tests/corpus/6252-fetch-input-url-unions.ts | 48 +++++++++++++++++++ .../fixtures/node-types/fetch-input-unions.ts | 48 +++++++++++++++++++ tests/harness/project-config.test.ts | 21 ++++++++ 7 files changed, 165 insertions(+), 13 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6252-fetch-input-url-unions.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fetch-input-unions.ts.json create mode 100644 tests/corpus/6252-fetch-input-url-unions.ts create mode 100644 tests/fixtures/node-types/fetch-input-unions.ts diff --git a/packages/compiler/src/frontend/lowering/lower-stdlib.ts b/packages/compiler/src/frontend/lowering/lower-stdlib.ts index 2f0e1c66f9..68a5145c3a 100644 --- a/packages/compiler/src/frontend/lowering/lower-stdlib.ts +++ b/packages/compiler/src/frontend/lowering/lower-stdlib.ts @@ -619,6 +619,20 @@ export function lowerFetchCall(lowerer: Lowerer, call: ts.CallExpression): IrExp : lowerer.lowerExpr(argument); stmts.push({ kind: "exprStmt", expr: discarded, loc: discarded.loc }); } + // A union input holding a URL arm (`string | URL`) converts through the + // checked-dynamic input path, which reads the URL's href at the call + // like the bare-URL arm below; narrowing it to a string would throw on + // the URL arm. + const unionWithUrl = + input.type.kind === "union" && + (lowerer.unions.get(input.type.unionId)?.arms.some((arm) => arm.kind === "url") ?? false) && + lowerer.dynConvertible(input.type); + const dynInput: IrExpr | null = + input.type.kind === "dyn" + ? inputRef + : unionWithUrl + ? { kind: "dynFrom", value: inputRef, type: DYN, loc: locOf(inputNode) } + : null; const url: IrExpr = input.type.kind === "url" ? { @@ -628,13 +642,13 @@ export function lowerFetchCall(lowerer: Lowerer, call: ts.CallExpression): IrExp type: STRING, loc: locOf(inputNode), } - : input.type.kind === "dyn" - ? inputRef + : dynInput !== null + ? dynInput : lowerer.coerceInto(inputNode, inputRef, STRING); const answer: IrExpr = { kind: "libCall", - fn: input.type.kind === "dyn" ? "fetch.input" : "fetch.start", - args: [input.type.kind === "dyn" ? inputRef : url, initRef], + fn: dynInput !== null ? "fetch.input" : "fetch.start", + args: [url, initRef], type: { kind: "promise", inner: DYN }, loc, }; diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 90b5d1e9f8..24ff477f53 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1360,8 +1360,9 @@ export function mapAsyncGeneratorAnnotation(type: ts.Type, ctx: TypeMapperCtx): if ( !symbol || !["AsyncIterator", "AsyncIterableIterator", "AsyncIterable"].includes(symbol.name) || - !symbol.declarations - .some((d) => ts.isInterfaceDeclaration(d) && ctx.isStdlibFile(d.getSourceFile())) + !symbol.declarations.some( + (d) => ts.isInterfaceDeclaration(d) && ctx.isStdlibFile(d.getSourceFile()), + ) ) return null; const args = checker.getTypeArguments(type as ts.TypeReference); @@ -2513,12 +2514,11 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if ( psym && WEBCRYPTO_DYN_TYPES.has(psym.name) && - psym.declarations - .some( - (d) => - (ts.isClassDeclaration(d) || ts.isInterfaceDeclaration(d)) && - ctx.isStdlibFile(d.getSourceFile()), - ) + psym.declarations.some( + (d) => + (ts.isClassDeclaration(d) || ts.isInterfaceDeclaration(d)) && + ctx.isStdlibFile(d.getSourceFile()), + ) ) { return DYN; } @@ -4184,7 +4184,9 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // semantics would DEGRADE under the collapse — class instances, // Maps/Sets, functions (closure identity, `x === String` // narrowing), promises, regexes, generators, handles — keep their - // existing union homes and fences below. A pending recursive + // existing union homes and fences below. URL is the + // exception: its handle boxes by reference and keeps every member, + // so it collapses too (dynSubsumableUnionArm). A pending recursive // placeholder cannot collapse (back-references already hold the // union id); the degenerate recursive spelling keeps its fence. if ( @@ -6285,6 +6287,13 @@ function dynSubsumableUnionArm(arm: IrType, ctx: TypeMapperCtx): boolean { case "undefinedT": case "nullT": return true; + // URL boxes BY REFERENCE (its checked-dynamic handle keeps identity, + // reads and writes its members through the native accessors, answers + // `instanceof URL`, and checks back out to the typed value), so it + // rides the collapse beside fetch's Request/Response handles: + // `string | URL | Request`, fetch's own input type under @types/node. + case "url": + return true; case "record": case "array": return canConvertToDyn( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6252-fetch-input-url-unions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6252-fetch-input-url-unions.ts.json new file mode 100644 index 0000000000..13010068a6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6252-fetch-input-url-unions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6252-fetch-input-url-unions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fetch-input-unions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fetch-input-unions.ts.json new file mode 100644 index 0000000000..15aba2051c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fetch-input-unions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/fetch-input-unions.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6252-fetch-input-url-unions.ts b/tests/corpus/6252-fetch-input-url-unions.ts new file mode 100644 index 0000000000..3394fe5b46 --- /dev/null +++ b/tests/corpus/6252-fetch-input-url-unions.ts @@ -0,0 +1,48 @@ +// `typeof globalThis.fetch` under @types/node takes `string | URL | Request`: +// unions holding URL handles beside fetch handles compile (URL boxes by +// reference into the checked-dynamic representation), and a custom fetch +// cast to that type through `unknown` keeps working, including an extra +// init member written through a record view (the dispatcher pattern). +import * as http from "node:http"; +type FetchImpl = typeof globalThis.fetch; +interface DispatchingInit { + method?: string; + headers?: Record; + dispatcher?: { name: string }; +} +function customFetch(input: string | URL, init?: DispatchingInit): Promise { + console.log("custom fetch", typeof input === "string" ? "string" : "url", init?.dispatcher?.name ?? "none"); + return fetch(input, { method: init?.method, headers: init?.headers }); +} +const server = http.createServer((req, res) => res.end(`ok ${req.method} ${req.url}`)); +server.listen(0, "127.0.0.1", async () => { + const address = server.address(); + const port = address !== null && typeof address === "object" ? address.port : 0; + const impl: FetchImpl = customFetch as unknown as FetchImpl; + const init: RequestInit = { method: "POST" }; + (init as Record).dispatcher = { name: "pinned" }; + const res = await impl(`http://127.0.0.1:${port}/a`, init); + console.log(res.status, await res.text()); + const viaUrl = await impl(new URL(`http://127.0.0.1:${port}/b`)); + console.log(viaUrl.status, await viaUrl.text()); + const direct = await fetch(new URL(`http://127.0.0.1:${port}/c`)); + console.log(direct.status, await direct.text()); + server.close(); +}); + +function describeInput(input: string | Request | URL): string { + if (typeof input === "string") return `string ${input}`; + if (input instanceof URL) return `url ${input.href} ${input.hostname}`; + return `request ${input.method} ${input.url}`; +} +console.log(describeInput("http://a.test/x")); +const u = new URL("http://b.test:8080/p?q=1"); +console.log(describeInput(u)); +console.log(describeInput(new Request("http://c.test/r", { method: "PUT" }))); +const items: (string | URL | Request)[] = [u, "s"]; +console.log(items.length, items[0] === u, String(items[0])); +let slot: string | URL | Request = u; +if (slot instanceof URL) { slot.pathname = "/changed"; } +console.log(u.href); +const params: string | Request | URL = new URL("http://d.test/?b=2"); +console.log(typeof params === "string" ? params : params instanceof URL ? params.searchParams.get("b") : "req"); diff --git a/tests/fixtures/node-types/fetch-input-unions.ts b/tests/fixtures/node-types/fetch-input-unions.ts new file mode 100644 index 0000000000..3394fe5b46 --- /dev/null +++ b/tests/fixtures/node-types/fetch-input-unions.ts @@ -0,0 +1,48 @@ +// `typeof globalThis.fetch` under @types/node takes `string | URL | Request`: +// unions holding URL handles beside fetch handles compile (URL boxes by +// reference into the checked-dynamic representation), and a custom fetch +// cast to that type through `unknown` keeps working, including an extra +// init member written through a record view (the dispatcher pattern). +import * as http from "node:http"; +type FetchImpl = typeof globalThis.fetch; +interface DispatchingInit { + method?: string; + headers?: Record; + dispatcher?: { name: string }; +} +function customFetch(input: string | URL, init?: DispatchingInit): Promise { + console.log("custom fetch", typeof input === "string" ? "string" : "url", init?.dispatcher?.name ?? "none"); + return fetch(input, { method: init?.method, headers: init?.headers }); +} +const server = http.createServer((req, res) => res.end(`ok ${req.method} ${req.url}`)); +server.listen(0, "127.0.0.1", async () => { + const address = server.address(); + const port = address !== null && typeof address === "object" ? address.port : 0; + const impl: FetchImpl = customFetch as unknown as FetchImpl; + const init: RequestInit = { method: "POST" }; + (init as Record).dispatcher = { name: "pinned" }; + const res = await impl(`http://127.0.0.1:${port}/a`, init); + console.log(res.status, await res.text()); + const viaUrl = await impl(new URL(`http://127.0.0.1:${port}/b`)); + console.log(viaUrl.status, await viaUrl.text()); + const direct = await fetch(new URL(`http://127.0.0.1:${port}/c`)); + console.log(direct.status, await direct.text()); + server.close(); +}); + +function describeInput(input: string | Request | URL): string { + if (typeof input === "string") return `string ${input}`; + if (input instanceof URL) return `url ${input.href} ${input.hostname}`; + return `request ${input.method} ${input.url}`; +} +console.log(describeInput("http://a.test/x")); +const u = new URL("http://b.test:8080/p?q=1"); +console.log(describeInput(u)); +console.log(describeInput(new Request("http://c.test/r", { method: "PUT" }))); +const items: (string | URL | Request)[] = [u, "s"]; +console.log(items.length, items[0] === u, String(items[0])); +let slot: string | URL | Request = u; +if (slot instanceof URL) { slot.pathname = "/changed"; } +console.log(u.href); +const params: string | Request | URL = new URL("http://d.test/?b=2"); +console.log(typeof params === "string" ? params : params instanceof URL ? params.searchParams.get("b") : "req"); diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index d98f12da37..226d3872a9 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -73,6 +73,27 @@ test("node-types: Headers, Request and Response members typed by undici-types ru expect(comparableStderr(native.stderr)).toBe(node.stderr); }); +test("node-types: fetch's string | URL | Request input and a custom fetch cast to typeof fetch", async () => { + const outDir = outDirFor("fetch-input-unions"); + const entry = join(nodeTypesDir, "fetch-input-unions.ts"); + const result = await compile(entry, { + outPath: join( + outDir, + process.platform === "win32" ? "fetch-input-unions.exe" : "fetch-input-unions", + ), + outDir, + sanitize, + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const [native, node] = await Promise.all([ + execFileAsync(result.binaryPath), + execFileAsync(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", entry]), + ]); + expect(native.stdout).toBe(node.stdout); + expect(comparableStderr(native.stderr)).toBe(node.stderr); +}); + test("node-types: http header arrays and server.address() follow @types/node's types", async () => { const outDir = outDirFor("http-headers-address"); const entry = join(nodeTypesDir, "http-headers-address.ts"); From c2ae71a6faf307e5661a4a6bcd73ac17f6ef657a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 02:07:00 -0500 Subject: [PATCH 4/5] Bound full-batch poller drains to 48 per poll phase A full batch of 64 events polled again without returning to the event loop. When synchronous socket callbacks kept 64 events ready on every drain, timers and setImmediate callbacks never ran. The poll phase now drains at most 48 full batches, the count libuv's uv__io_poll uses, and the budget carries across the reopen that lets microtasks run, so the remaining events wait for the next loop iteration. --- packages/runtime/src/scr_async.c | 13 +++++++++++-- packages/runtime/src/scr_net.c | 8 +++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index de48d6b616..2ab35e85d0 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -4153,13 +4153,19 @@ bool scr_loop_has_ready(void) { return scr_ready_len > 0 || scr_nt_head != NULL; * drains the poller at most once between check phases. I/O that becomes * ready while this iteration's callbacks run (a loopback connect started * by one of them) waits for the next iteration, so immediates queued in - * the meantime fire first — Node's poll → check order. */ + * the meantime fire first — Node's poll → check order. A full batch polls + * again without waiting, at most SCR_LOOP_IO_BATCHES times per phase (the + * count libuv's uv__io_poll uses); later events wait for the next + * iteration, so timers and immediates still run while sockets stay busy. */ +enum { SCR_LOOP_IO_BATCHES = 48 }; static SCR_TL bool scr_loop_io_open = true; +static SCR_TL int scr_loop_io_batches = 0; bool scr_loop_io_take(void) { bool open = scr_loop_io_open; scr_loop_io_open = false; return open; } +bool scr_loop_io_more(void) { return ++scr_loop_io_batches < SCR_LOOP_IO_BATCHES; } void scr_loop_io_reopen(void) { scr_loop_io_open = true; } /* The process.nextTick queue to exhaustion, ticks queued meanwhile @@ -4668,7 +4674,10 @@ static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { * had nothing to run). A check phase deferred for pending microtasks * keeps it closed, so the next iteration reaches the check phase * before polling again. */ - if (check_ran || scr_pending_immediates == 0) scr_loop_io_open = true; + if (check_ran || scr_pending_immediates == 0) { + scr_loop_io_open = true; + scr_loop_io_batches = 0; + } } return rejection_failed; } diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index 9684aebe97..f12840d91a 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -4909,6 +4909,7 @@ static int scr_net_pollfd(void) { } bool scr_loop_io_take(void); +bool scr_loop_io_more(void); void scr_loop_io_reopen(void); static void scr_net_dispatch(void) { @@ -4917,8 +4918,9 @@ static void scr_net_dispatch(void) { return; } /* One poller drain per loop iteration (scr_loop_io_take); a full batch - * keeps draining, like libuv's poll phase. Deferred emits (the sweep) - * still run on every call. */ + * keeps draining within the phase's batch budget (scr_loop_io_more), like + * libuv's poll phase. Deferred emits (the sweep) still run on every + * call. */ bool may_drain = scr_loop_io_take(); for (;;) { scr_net_sweep(); @@ -4928,7 +4930,7 @@ static void scr_net_dispatch(void) { int n = 0; if (may_drain) { n = scrp_drain(scr_net_poller, evs, 64); - may_drain = n == 64; + may_drain = n == 64 && scr_loop_io_more(); } for (int i = 0; i < n; i++) { void *udata = evs[i].udata; From 33354cc4817fcad1726937085babbee55ab80722 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 10:50:19 -0500 Subject: [PATCH 5/5] Abort the request of a redirect that fetch follows or rejects A redirect fetch followed or rejected was marked to drain: its body was read and dropped until the server ended it, and the abort listener was already removed. A body that never ends kept the connection busy after cancellation and stalled a pool of one connection. Following or rejecting a redirect now aborts its request, so the connection closes. undici 7 leaves that connection open with its body paused, which stalls the same pool; the follow-up request here gets a new connection. --- packages/compiler/shims/undici/core/fetch.ts | 9 +++++++-- .../package-shims/undici-hostile-peer.mjs | 15 +++++++++++++++ .../fixtures/package-shims/undici-redirect.ts | 19 +++++++++++++++++++ tests/harness/package-shims.test.ts | 17 ++++++++++++++++- 4 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/package-shims/undici-redirect.ts diff --git a/packages/compiler/shims/undici/core/fetch.ts b/packages/compiler/shims/undici/core/fetch.ts index 54a0ce0d52..ebca55d717 100644 --- a/packages/compiler/shims/undici/core/fetch.ts +++ b/packages/compiler/shims/undici/core/fetch.ts @@ -117,8 +117,9 @@ interface RawResponse { status: number; statusText: string; headers: string[]; - /** null when the response has no body to expose (null-body status, HEAD, - * or a redirect fetch follows); its bytes are then read and dropped. */ + /** null when the response has no body to expose: a null-body status or + * HEAD (its bytes, if any, are read and dropped), or a redirect fetch + * follows or rejects (the request is aborted, closing its connection). */ body: ReadableStream | null; } @@ -520,6 +521,10 @@ function dispatchOnce( dump = true; stopListening(); resolve({ status: statusCode, statusText: text, headers: rawHeaders, body: null }); + // A redirect that is followed or rejected never reads its body: + // abort the request so the connection is released instead of + // draining a body that may never end. + if (follows) abortRequest(new DOMException("The operation was aborted.", "AbortError")); return true; } const codings = diff --git a/tests/fixtures/package-shims/undici-hostile-peer.mjs b/tests/fixtures/package-shims/undici-hostile-peer.mjs index ce6c5881c6..b55e3226f3 100644 --- a/tests/fixtures/package-shims/undici-hostile-peer.mjs +++ b/tests/fixtures/package-shims/undici-hostile-peer.mjs @@ -3,6 +3,9 @@ // /zeros a chunk size with 64 MiB of leading zeros, // /big a 64 MiB body, as fast as the socket takes it, // /bomb 64 MiB of zeros, gzipped, +// /redirect-endless a 302 to /redirect-closed whose body never ends, +// /redirect-closed "closed" once the last /redirect-endless connection +// closed (waiting up to a second), else "open", // and exits when its stdin closes. import * as net from "node:net"; import * as zlib from "node:zlib"; @@ -24,6 +27,8 @@ function stream(s, head, piece, tail) { pump(); } +let redirectClosed = Promise.resolve(); + const server = net.createServer((s) => { s.on("error", () => {}); let head = ""; @@ -39,6 +44,16 @@ const server = net.createServer((s) => { stream(s, chunked, Buffer.alloc(64 * 1024, 48), "5\r\nhello\r\n0\r\n\r\n"); } else if (path === "/big") { stream(s, `HTTP/1.1 200 OK\r\nContent-Length: ${size}\r\n\r\n`, Buffer.alloc(64 * 1024, 97), ""); + } else if (path === "/redirect-endless") { + redirectClosed = new Promise((resolve) => s.on("close", resolve)); + s.write("HTTP/1.1 302 Found\r\nLocation: /redirect-closed\r\nTransfer-Encoding: chunked\r\n\r\n"); + const timer = setInterval(() => s.write("4\r\ndata\r\n"), 5); + s.on("close", () => clearInterval(timer)); + } else if (path === "/redirect-closed") { + const timeout = new Promise((resolve) => setTimeout(() => resolve("open"), 1000)); + void Promise.race([redirectClosed.then(() => "closed"), timeout]).then((state) => { + s.write(`HTTP/1.1 200 OK\r\nContent-Length: ${state.length}\r\n\r\n${state}`); + }); } else { s.write(`HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: ${bomb.length}\r\n\r\n`); s.write(bomb); diff --git a/tests/fixtures/package-shims/undici-redirect.ts b/tests/fixtures/package-shims/undici-redirect.ts new file mode 100644 index 0000000000..de9700fcfc --- /dev/null +++ b/tests/fixtures/package-shims/undici-redirect.ts @@ -0,0 +1,19 @@ +// Follows and rejects redirects whose bodies never end +// (undici-hostile-peer.mjs, port in argv[2]) through a pool of one +// connection. The discarded redirect's request is aborted, so its +// connection closes, the follow-up request gets a connection, and the peer +// reports the redirect's connection closed. +import { Agent, fetch } from "undici"; + +const origin = `http://127.0.0.1:${process.argv[2]}`; +const dispatcher = new Agent({ connections: 1 }); +const followed = await fetch(`${origin}/redirect-endless`, { dispatcher }); +console.log("follow", followed.status, await followed.text()); +try { + await fetch(`${origin}/redirect-endless`, { dispatcher, redirect: "error" }); +} catch (err) { + console.log("error mode", (err as Error).name, (err as Error).message); +} +const after = await fetch(`${origin}/redirect-closed`, { dispatcher }); +console.log("after error mode", await after.text()); +await dispatcher.close(); diff --git a/tests/harness/package-shims.test.ts b/tests/harness/package-shims.test.ts index 0be52d2df2..447052b81f 100644 --- a/tests/harness/package-shims.test.ts +++ b/tests/harness/package-shims.test.ts @@ -10,7 +10,10 @@ * - the undici shims keep memory bounded against a peer that sends endless * chunk-size lines and large or compressed bodies, and the 5.x profile * rejects a Host value with CR or LF as undici 5.29 does (the vendored - * 5.12 writes it into the request). + * 5.12 writes it into the request); + * - fetch aborts a redirect it follows or rejects, so a redirect body that + * never ends does not keep its connection busy (undici 7 leaves that + * connection open with its body paused). */ import { execFile, spawn } from "node:child_process"; import { copyFileSync, cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; @@ -164,6 +167,18 @@ describe("package shims", () => { }, 300_000); } + test("undici7: fetch closes the connection of a redirect it follows or rejects", async () => { + const { dir, binary } = await buildInCase("undici7", "undici-redirect.ts"); + expect(await runAgainstPeer(binary, dir)).toBe( + [ + "follow 200 closed", + "error mode TypeError fetch failed", + "after error mode closed", + "", + ].join("\n"), + ); + }, 180_000); + test("undici5: a Host value with CR or LF throws before the request is written", async () => { const { dir, binary } = await buildInCase("undici5", "undici5-host.ts"); // undici 5.29.0's output.