Skip to content

Lower fetch members typed by @types/node, and run the poll phase once per loop iteration - #1435

Merged
ctate merged 7 commits into
opt/native-undicifrom
opt/native-undici-followups
Oct 20, 2026
Merged

ctate merged 7 commits into
opt/native-undicifrom
opt/native-undici-followups

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Stacked on #1425.

Summary

Fetch-family members under @types/node. undici-types, which supplies @types/node's fetch declarations, declares the Headers methods and the Request/Response body mixin as function-typed properties (readonly append: (name: string, value: string) => void). scriptc's own declarations use methods.

The receiver-call lowering took a property-declared member to be a stored callback. Under @types/node:

  • Headers.forEach was refused at build time.
  • append, get, set, delete, has and getSetCookie built, then threw … is not a function at run time.

A member whose declarations all come from the standard library now takes the shared receiver dispatch, whichever declaration file supplies its type.

The rest of the fetch family behaves the same with either set of declarations: Response status, headers and body methods, Response.json, Request reads, and AbortController. Iterating a Headers (keys, values, entries, for-of) stays refused under both.

Poll phase ordering. The event loop restarts its turn whenever a callback queues microtasks, and every restart drained the net poller again. So I/O that became ready during the iteration's own callbacks, such as a loopback connect started by a 'data' handler, ran before the check phase. Immediates queued earlier then ran after it. Node delivers that I/O in the next iteration's poll phase.

The net unit now drains its poller once per loop iteration. A full batch keeps draining. The poller reopens after the check phase has run, or when no immediates are pending. Deferred emits still run on every call.

The visible effect: back-to-back await request() calls on an undici Agent now open the same number of connections as Node (2, not 3), so the undici fixtures no longer need a setImmediate turn between requests.

Evidence

  • tests/fixtures/node-types/fetch-handle-members.ts: Headers, Request and Response members typed by undici-types, run against Node through project-config.test.ts.
  • Corpus 6251-net-connect-poll-phase-order: the order of a new connection's 'connect' against nextTick, promise jobs and setImmediate, directly and through awaited promises. It fails with the previous runtime and matches Node with this one.
  • The undici5/undici7 npm cases drop the setImmediate workaround and still match Node, plain and sanitized.
  • Event-loop, server, fetch, dgram, portless and native-proxy suites, plus the differential corpus filtered to I/O, timers, streams and promises: the failure set is identical with and without the runtime change, apart from 6251, which only the old runtime fails. Every remaining failure is environmental (the sandbox runs Node 24.15; main pins 24.22). The sanitized lane shows the same.
  • Zero-cost gate against Compile undici from a built-in implementation #1425: IR and LLVM are identical for every program. Executables that link the event loop differ only in scr_loop_run and the new scr_loop_io_open (+40–128 B), which is the intended runtime change.
  • Runtime benchmarks against Compile undici from a built-in implementation #1425 (4 layouts × 16 runs): async-pipeline 9.88 → 9.97 ms, cli-config 1.68 → 1.69 ms, http-api 490.8 → 488.1 ms, server throughput 81.5k → 81.9k req/s. Geomean +0.3%, within noise. Self-compile −0.6%.
  • Lint, format, test:ts7 --baselines-only (2 new baselines), and the self-host IR build (exit 0, no diagnostics).

🤖 Generated with Claude Code

cramforce and others added 2 commits October 19, 2026 18:53
…s 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 20, 2026 3:51pm UTC

Comment thread packages/runtime/src/scr_net.c Outdated
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 <noreply@anthropic.com>
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.
@vercel-security-reviewer

Copy link
Copy Markdown

Security review details

@vercel vercel Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings

  1. packages/compiler/shims/undici/core/fetch.ts:517-523 — When a redirect response has an unfinished body, removing its abort listener without cancelling the request leaves its connection occupied even after fetch finishes or the caller aborts.

    Fix on Vercel

Replaces the single-parent "Merge main" squash (6ab9a54) with a two-parent merge of the same tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
@ctate
ctate merged commit fdd1c9e into opt/native-undici Oct 20, 2026
58 of 67 checks passed
@ctate
ctate deleted the opt/native-undici-followups branch October 20, 2026 16:49

This branch was successfully deployed

1 active deployment
Preview — 33354cc4 Deployed Oct 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants