Skip to content

Windows: make the platform buildable and Node-correct end-to-end (#10385) - #10403

Open
proggeramlug wants to merge 191 commits into
mainfrom
codex/win-10385
Open

proggeramlug wants to merge 191 commits into
mainfrom
codex/win-10385

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Runs the #10385 Windows work order against turnloop/integration, on a real
Windows 11 / MSVC box. The branch did not compile when this started; it now
builds, and passes 790 of 835 gap fixtures (94.6%) against Node 26.5.1 with
perry-runtime green at 3942/0.

Ten defects, one of them turnloop's and nine pre-existing on main:

# defect impact
1 cfg(windows) fork path never migrated to P2's CpPipe turnloop; branch did not build
2 RS4GC vs WinEH funclets (#7354) Windows had no precise GC roots at all
3 lru_subclass unlinkable (#10293) compiler and user programs
4 err.errno negated-OS rather than libuv's, ×3 sites every Node error object
5 perry-ui-windows-winui missing reorder_child made windows-build red, and masked 2 and 3
6 funnel-assert test vs the perry-dev profile would newly redden CI
7 malloc_trim test keyed on a stale OS list would newly redden CI
8 compat matrix PERRY_BIN without .exe harness could not start
9 compat matrix probe output without .exe reported working modules as broken
10 compat matrix 300s compile timeout fabricated 10 prefix "bugs"

The through-line is that each break hid the next. windows-build dies on a UI
crate before attempting any link, so two unconditional link failures were
invisible to CI, and two more test failures sat behind those.

Two worth reading closely

Windows gets precise GC roots. #7302 moved EH to invoke/landingpad
because longjmp could skip a statepoint relocation write-back (#7174); on
windows-msvc that lowering becomes SEH funclets, and
rewrite-statepoints-for-gc dies on funclets (reproduced on LLVM 22.1.8, not
just the 22.1.3 the refusal cites). So Windows could have precise roots or
try, never both — and since essentially all async lowers to a try, the whole
turnloop surface was unreachable except with the collector in fallback mode.

The triple was never the problem: RS4GC behaves identically on Windows and Linux
triples for non-funclet IR. Windows now uses the same landingpad shape as
ELF/Mach-O with Perry's own personality, which makes LLVM emit
.seh_handler perry_eh_personality plus an Itanium GCC_except_table on COFF;
a new x64 language handler walks that LSDA and transfers via RtlUnwindEx.
Verified live — PERRY_EH_TRACE shows the personality decoding a real pad per
catch, output is byte-identical to Node across nested rethrow and finally, and
.pgcmap is present (1216 B) under RS4GC and absent without it, so the
statepoints are real rather than a silently-skipped pass.

§3's connection ceiling, measured. 10,000/10,000 connections accepted, so
#10351's fix holds on IOCP. Idle cost is materially worse than Linux and the A/B
attributes it: 69.0 MB at max_operations = 32_768 vs 32.9 MB at 2_048 (10.3 MB
with no net loop), i.e. ~36 MB is the ceiling alone — matching
(32768-2048) x 1096 B for the eager, non-paged IOCP Kernel slab. 8,700
bytes/connection against Linux's 6,228.

max_operations is deliberately not lowered. A connection costs two handles
and an armed read, so a smaller operation ceiling is a connection ceiling under
another name and would reinstate exactly the refusal #10351 removed. The fix
belongs upstream: the slab must be contiguous, not committed — reserve the range,
commit to the high-water mark. Recorded in net_config() with the numbers.

Not done

  • §1's two-arm A/B. Needs a second full build; the box cannot hold both
    trees' target/ (a full ext-routed gap run transiently wants ~20 GB of
    auto-optimize scratch). The 39 parity failures are therefore untriaged as to
    whether the migration caused them. This is the next task.
  • test_gap_turnloop_net_sockets is diagnosed, not fixed: a failed AF_UNIX
    listen() reports EINVAL where Node reports EACCES, and socket_events.rs
    prints a diagnostic where Node throws the unhandled 'error'. The latter is a
    deliberate, documented cross-platform deviation, so changing it from a
    Windows ticket seemed wrong.
  • 4 genuine prefix divergences (sea, sqlite, test, test/reporters),
    reproduced cold and warm.

Filed separately

An auto-optimize cache-coherence defect found here: archives are kept on
auto_optimized_source_fingerprint but validated at link against
perry-runtime's build stamp, so a one-commit-stale archive is judged fresh,
reused, then fatally rejected. It silently turned 7 passing gap fixtures into
"compile failures" — indistinguishable from a real regression on CI.

Full detail, including every measurement, is in
changelog.d/10385-windows-support.md.

Ralph Küpper and others added 30 commits September 15, 2026 06:34
The checkpoint is reverted wholesale rather than amended; the P0 work is
redone in the following commits. Reasons, per area:

- Cargo.lock was hand-spliced (dependency lists out of cargo's order), not
  produced by cargo.
- The turnloop wake took a process-wide mutex on every js_notify_main_thread
  from a thread other than the loop owner, and skipped notifying for
  same-thread producers without re-checking the flag before the OS wait.
- Keep-alive conversions in perry-ext-net, TLS and worker_threads were
  applied by regex and missed state transitions (for example TLS server-side
  sockets whose command channel changes, ext-net servers' bun_tcp ref state).
- MessagePort/BroadcastChannel `onmessage` became an accessor property, a
  JS-observable change.
- setTimeout delay normalization changed from 0 ms to 1 ms, a timer
  semantics change outside P0.
- Timer liveness allocated an Arc and took a global HashMap lock per timer.
turnloop 0.1.0-alpha.2 (crates.io, published 2026-09-15T01:10:00Z) is added
as an exact workspace dependency and to perry-runtime for native targets.
Checksum 21053fd229e6437ba256b97b2e491dbb5e777ae14f8e585199d17dd9b46b6493,
matching the crates.io index entry. Locked once with the owner-approved
one-time publish-age override for this version
(CARGO_RESOLVER_INCOMPATIBLE_PUBLISH_AGE=allow); later builds use --locked.

turnloop pins its own dependencies exactly (libc =0.2.175, js-sys =0.3.85,
wasm-bindgen =0.2.108, windows-sys =0.61.2, wasip2 =1.0.3, loom =0.7.2 under
cfg(loom)). Cargo cannot hold two semver-compatible copies of those crates, so
the resolver downgraded the workspace:

  libc 0.2.189 -> 0.2.175       tokio 1.53.1 -> 1.50.0
  tokio-macros 2.7.0 -> 2.6.1   mio 1.2.1 -> 1.1.0
  redis 1.6.0 -> 1.2.4          rustix 1.1.4 -> 1.1.2
  linux-raw-sys 0.12.1 -> 0.11.0  tempfile 3.27.0 -> 3.23.0
  js-sys / web-sys 0.3.99 -> 0.3.85
  wasm-bindgen(-macro,-macro-support,-shared) 0.2.122 -> 0.2.108
  wasm-bindgen-futures 0.4.72 -> 0.4.58
  added: generator 0.8.9, loom 0.7.2 (cfg(loom) only)

redis 1.2.4 carries a future-incompatibility warning. This needs an owner
decision; the fix belongs in turnloop (caret requirements instead of `=`).
Wait driver (perry-runtime event_pump/agent_loop.rs, precise_wait.rs):
- The primary agent owns a thread-local turnloop::Loop, created by its first
  real park and destroyed at the process-exit funnel. There is no
  process-global loop; the only global is the primary agent's notifier route
  plus an in-turn flag. Worker agents (and a second thread acting for the
  primary agent) keep the legacy park until P3/P4.
- js_wait_for_event computes the park deadline as an Instant from the timer
  queues, the stdlib provider, the loop's own deadline and the idle cap, and
  waits with one Loop::turn(Timeout::Until(deadline)). No whole-millisecond
  truncation and no 1 ms floor, so a sub-millisecond remainder is one OS wait
  instead of a spin. The GC idle hook is offered budgets of >= 1 ms only; its
  verdict's remainder is already encoded in the absolute deadline.
- Wake: js_notify_main_thread stores NOTIFIED and then loads the in-turn flag;
  the owner sets the flag and re-reads NOTIFIED before turning (SeqCst
  handshake). Outside a turn a notify costs one atomic load: no lock, no
  syscall, no stale turnloop notification.
- fast(): a nonblocking turn only when the loop has outstanding work (never in
  P0, which submits no operation).
- The #1114 spin throttle stays as a safety net for a deadline source that
  reports a due deadline nothing consumes; it is no longer the sub-ms path.
- PERRY_LOOP_STATS=1 prints turns, OS waits, zero-event waits, transitional
  tokio ticks and turn errors at exit (diagnostic only).

P0-transitional tokio coexistence (deleted by P8):
- stdlib registers js_register_native_inflight: tokio's alive-task count or
  EXT_BLOCKING_TASKS_INFLIGHT, both O(1). While it reports work the primary
  agent drives the existing registered tokio tick exactly as before; otherwise
  it turns its loop. The fast path still calls the unchanged
  stdlib_fast_drive.
- spawn_native / js_native_work_submitted wake a primary agent parked in a
  turn when native work appears from another thread.
- Cargo feature perry-stdlib/tokio-wait-driver (default off, forwards to
  perry-runtime/tokio-wait-driver) compiles the pre-P0 park for every agent,
  for A/B measurement.

FFI shape changes: new js_register_native_inflight and
js_native_work_submitted; the stdlib next-wake provider now returns
fractional milliseconds (readline's ESC deadline no longer rounds up outside
the A/B arm). The C js_*_timer_next_deadline and perry_next_wake_ms keep their
whole-millisecond shape.

O(1) keep-alive, each maintained where state starts and ends:
- timer queues: a primary-agent count per queue (TimerQueue), with the ref
  state cached on callback/interval entries; debug builds re-derive the count
  on every read;
- native async completions, thread results and diagnostics publishes: length
  mirrors published under their locks;
- extension has-active registry: length gate; stdin listeners: armed latch;
  IPC: probe/available atomics;
- stdlib: pending resolution length mirrors, TLS server/socket count, live
  referenced worker count;
- EXT_BLOCKING_TASKS_INFLIGHT references are RAII guards, so a panicking or
  cancelled native task no longer leaks an increment.

Tests: agent-loop install/shutdown/thread-exit, cross-thread wake through
js_notify_main_thread with a counted wake syscall, 0.5/2/10 ms deadlines with
an idle socket (<= 2 turns, <= 1 zero-event wait), a real Perry timer through
js_wait_for_event, worker decline, fast-path OS-call freedom; counter balance
for timers (fire, cancel, unref/ref/refresh, agent purge), TLS listeners
(listen/close, bind error, early close) and the in-flight guard (success,
error, panic, abort, unpolled drop).
- test-files/test_turnloop_p0_*.ts: 0.5/2/10 ms timeouts, a sub-millisecond
  remainder, an interval, promise churn and an idle wait.
- scripts/turnloop_p0_loop_stats.py compiles them with a prebuilt compiler,
  compares stdout with the pinned Node oracle and asserts from
  PERRY_LOOP_STATS=1 that deadlines were reached in <= 2 turns and <= 1
  zero-event wait per expiry, that a real wait happened where one is due, and
  that no tokio tick ran; --arm tokio-wait-driver checks the A/B arm instead.
- scripts/turnloop_p0_native_probe.py proves the transitional tokio bridge
  still runs fetch and WebSocket work (server-side request counts,
  native_ticks > 0).
- test_gap_turnloop_p0_timers.ts: timer ordering, remainder, interval,
  ref/unref and promise churn across the precise park.
- docs/turnloop/p0-report.md: design, FFI changes, dependency downgrades,
  every verification command with its result, measured counters, integrator
  commands, open P1-P4 items and turnloop API gaps.
- changelog.d/turnloop-p0-wait-driver.md.
Turns and OS waits do not say where the time goes. Instruction counts and RSS
can stay flat while the waits between Perry and tokio decide a server's latency
and CPU, so PERRY_LOOP_STATS=1 now records, for the primary agent:

- per wait kind (turnloop turn, transitional tokio tick, condvar park) the
  count, total and maximum time parked, taken around the wait itself;
- the count, total and maximum time of stdlib fast drives that actually drove
  tokio (the notified path's brief tick);
- wake latency from a producer's notify to the parked wait returning, as a
  <50us / <200us / <1ms / <5ms / >=5ms histogram plus the maximum. Every
  producer is covered because they all fan out through js_notify_main_thread:
  cross-thread ones and in-thread native completions alike;
- zero-budget returns and #1114 spin-throttle sleeps.

One `[perry-loop-waits] arm=<arm> key=value` line at the process-exit funnel,
in both A/B arms, so a run proves which driver produced its numbers and the two
arms are comparable like with like: the same tokio tick is instrumented whether
`tokio-wait-driver` is on or off.

Diagnostic only. With PERRY_LOOP_STATS unset every hook is one relaxed load of
a lazily resolved state byte; nothing allocates and nothing locks on a wait
path. Recording is limited to the primary agent so a worker's legacy park
cannot blur the comparison.

Tests (both arms, RUST_TEST_THREADS=1):
- one cross-thread notify into a parked condvar park, into a parked registered
  tick, and into a parked turnloop turn each produce exactly one wake-latency
  sample; a wait that merely times out and a notify outside any wait produce
  none;
- the bucket edges sit exactly at 50us/200us/1ms/5ms;
- a live tokio task makes the primary agent's park a tokio tick and not a turn
  (perry-runtime with a registered predicate and tick; perry-stdlib through the
  real shared runtime, asserting the task ran and the park lasted);
- fast drives, zero-budget returns and throttle sleeps are counted, the
  zero-budget one through the real js_wait_for_event entry;
- worker agents are not recorded, and the exit line carries every field.

Sabotage-checked, both reverted: dropping note_notify() from
js_notify_main_thread fails the three wake-sample tests (0 vs 1); dropping the
timing around the registered tick fails the two tokio-tick tests.
scripts/turnloop/server_ab.py measures the thing the wait metrics are for: a
real server under load, both arms, on one commit.

build   — one cargo invocation per arm into its own target dir with the same
          package set and the documented no-auto-optimize http feature set,
          recording every archive's mtime, size and SHA-256 (and warning when
          one predates HEAD, i.e. a stale .a), then compiling the same app with
          each compiler and verifying the arm marker and the `arm=` field of
          the PERRY_LOOP_STATS line before anything is measured.
          --skip-cargo takes arms that are already built, for a host with room
          for only one cargo target tree.
run     — interleaves the arms over N rounds (order alternates per round): load
          at each requested concurrency (1, 64, 1024 by default) and idle
          keep-alive capacity (10k and 100k). Per sample: throughput, p50/p99/
          p999, CPU user/sys for the measured window and the process lifetime,
          wall, voluntary and involuntary context switches, syscalls/s, peak
          RSS, threads, bytes per idle connection, binary size, and the full
          wait metrics. A sample whose marker or `arm=` does not match the arm
          it was meant to measure is marked invalid and excluded, with the
          reason reported, rather than averaged in.
report  — one markdown table plus summary.json: median [min-max] per arm and
          the delta of medians.

Load generator: oha, else wrk (install instructions printed when neither
exists); ab only on request, for smoke runs. Syscalls: perf stat -e
raw_syscalls:sys_enter over the measured window, else strace -c -f in a
separate server process (perturbing, and labelled as such).

--dry-run prints the plan and drives the summary and markdown code over
generated samples, so the reporting path is exercised on macOS where the load
tools and /proc are not.

The subject is scripts/turnloop/apps/node_http_hello.ts rather than an existing
fastify or hono app: the A/B feature does not survive auto-optimize, and the
arms are only valid with prebuilt archives, which rules those out. It sets
keepAliveTimeout = 0 so idle sockets survive the capacity test, and exits
through process.exit on SIGTERM so the exit funnel prints the stats lines.
Three fixes to the harness, and the report sections for this lane.

- The build step now fails when the two arms share an identical
  libperry_runtime.a or libperry_stdlib.a, or link an identical server: that
  means the tokio-wait-driver feature never reached the build and every
  comparison below it would be vacuous. This is the check that matters; the
  archive-mtime line is demoted to a note, because cargo legitimately skips a
  crate whose inputs did not change and the old wording cried wolf on every
  cached run.
- oha's JSON flag moved from `-j` to `--output-format json`; try the new
  spelling and fall back, instead of silently reporting no samples.
- Create the work directory before compiling into it, and survive a server
  that was already reaped (no rusage) by marking the sample invalid rather
  than raising.

docs/turnloop/p0-report.md gains: what each wait-metric field measures and what
it costs when off, the exact integrator command lines for the harness and what
its build step verifies before measuring anything, the per-counter test map, and
the two new sabotage results. The example stats line is a real macOS run.
`server.keepAliveTimeout = 0` was meant to stop idle sockets being reaped. Under
Perry's node:http it does the opposite of what it does under Node: Node reads 0
as "never time out", Perry reads it as "no keep-alive" and answers
`Connection: close`. Every connection the idle test opened was closed
immediately, so the scenario reported 0 surviving connections and no
per-connection memory at all — a gate that ran and measured nothing.

Measured on macOS, arm A: with `= 0` the response carries `Connection: close`
and the socket is unusable after 0.3 s; with the setter dropped, or set to
600000, it carries `Connection: keep-alive` and is still reusable after 4 s.
The app now sets 600_000 and says why.

Also report per-connection memory from an RSS sample taken with the connections
open and BEFORE the hold, so the number exists even when a server reaps the
sockets during the hold; the post-hold figure is kept separately as "bytes per
surviving connection", alongside how many opened and how long that took.

With the fix, 2000 idle keep-alive connections: 2000 of 2000 survive a 3 s hold
in both arms, at ~30.9 KB of RSS per connection.
`js_native_work_submitted` wakes a parked turnloop turn directly through
`agent_loop::wake_primary()`, NOT through `js_notify_main_thread` — that is the
whole reason it exists, since tokio's own driver unpark cannot reach a turnloop
wait from another thread. It therefore never stamped the wake-latency clock, so
the turn's duration was counted but the wake that ended it produced no sample.

That is precisely the wake the A/B is about: a cross-thread native submission
into a parked primary agent. The turnloop arm's histogram was silently missing
it while the tokio arm's was not, which is the one thing a like-for-like
comparison may not do. It also contradicted this module's own documented
invariant that every producer is covered.

Also reject a stamp older than the wait it is ending. A producer preempted
between reading its clock and its compare-exchange can land a stamp belonging to
wait N on wait N+1, where the latency would be measured from before that wait
began — a multi-millisecond wake invented out of a scheduler hiccup. Rejecting
it costs one sample and makes the module's bias one-sided by construction: it
can under-report a wake, never invent or inflate one.

Test: a cross-thread `js_native_work_submitted` into a parked turn is one turn
and exactly one wake-latency sample. Sabotage-checked (reverted): with the stamp
removed, that test alone fails, 0 vs 1.

Harness fixes from the same review:
- `Server.start_or_kill()` at all four call sites. The health check can time out
  with the process alive and holding its port; every caller starts the server
  before its try/finally, so the orphan survived the whole run — and a contended
  host is exactly where the check times out.
- the forced-kill path now survives a child reaped elsewhere, like the poll
  above it already did;
- the marker-verification temp directory is removed instead of leaked;
- when both oha JSON spellings fail, report both errors, not just the last.
Sockets on turnloop handles (DESIGN §12 P1): a TCP or local listener, its
multishot accept, every accepted connection, client connect with hostname
resolution off the loop thread, multishot reads, ordered writes with queued-byte
backpressure, write-side shutdown and exactly-once close.

The core lives in perry-runtime because the loop does, and a net binding is a
separately linked staticlib that cannot hold a &mut Loop; crates/perry-runtime/
src/turnloop_net/abi.rs is the C ABI it uses instead, shaped like the event
pump's existing registration surface.

Routing needs no side table: the submission token carries the operation class
in its top 8 bits and the Perry-side id in the low 56, so a completion names
its socket and its syscall without a lookup and a stale token finds no entry.

No JS heap memory is handed to the driver at any point — reads land in
turnloop's pooled buffers and are copied into JS values by the sink on the
owning thread, writes arrive as an owned Vec the caller already copied out of
the JS value — so there is no buffer to root across a collection.

The loop is created at a wait-sized profile and upgraded to a net-sized one on
the first submission, so a timer-only program keeps P0's footprint.

Client connect walks the whole resolved address list, one attempt at a time,
because localhost resolves to ::1 first on a dual-stack host and an IPv4-only
listener must still be reachable (Node's autoSelectFamily).
…nned down

turnloop 0.1.0-alpha.3 (crates.io, published 2026-09-15T09:37:32Z, checksum
c3370511f37b90dc5ba694566cb941f0e89c205c84b798277f17a05f13e4a8ab) replaces its
own exact dependency pins with caret requirements. Those pins were what dragged
this workspace's libc, tokio, redis and the wasm-bindgen family backwards when
P0 took alpha.2; with them gone the lockfile is restored to the versions `main`
resolved before P0:

  libc 0.2.175 -> 0.2.189, tokio 1.50.0 -> 1.53.1, redis 1.2.4 -> 1.6.0,
  wasm-bindgen 0.2.108 -> 0.2.122 (with js-sys, web-sys, wasm-bindgen-futures
  and the macro crates), mio 1.1.0 -> 1.2.1, rustix 1.1.2 -> 1.1.4,
  linux-raw-sys 0.11.0 -> 0.12.1, tempfile 3.23.0 -> 3.27.0, and num-bigint
  0.5.1 back with redis.

The workspace requirement is a caret too, for the same reason: the exact
version is the lockfile's job, and an `=` requirement here would propagate the
problem alpha.3 just fixed. Locked once with the owner-approved one-time
publish-age override, then built --locked.

alpha.3 also adds filesystem categories to `ErrorKind` for its typed file
operations. The Node error mapper covers them explicitly (EACCES, EEXIST,
ENOTDIR, EISDIR, ENOTEMPTY) rather than folding them into UNKNOWN, so P2's
pipes and P4's file jobs inherit a real code rather than a placeholder.
`node:net`'s transport for every socket class that cannot be TLS-upgraded now
submits to the agent's turnloop loop instead of running on tokio:

  - one multishot `accept_start` per listener replaces a `spawn_async` accept
    loop per server (TCP in lib.rs, UDS and named pipes in ipc.rs), including
    its `oneshot` shutdown channel;
  - one multishot `read_start` per connection replaces a `run_socket_task`
    selecting on `read_buf` and a command channel;
  - `write` / `shutdown` / `close` are submitted where the FFI call happens,
    so a write no longer travels through a per-socket `mpsc` to reach the
    kernel, and `bytes_queued` is the driver's own count rather than a
    hand-maintained tally.

Nothing downstream changed: the same `PendingNetEvent`s go into the same queue
in the same order and are drained by the same `js_ext_net_drain_pending`, so
the JS surface, the listener maps, the GC root scanner and the read buffer pool
do not know which transport ran. `SocketState::command` is the one choke point
that picks a transport, so neither can be reached by accident.

Outbound TCP clients deliberately stay on tokio. `socket.upgradeToTLS` hands a
live `TcpStream` to `tokio_rustls` mid-stream (Postgres' SSLRequest flow,
`test_net_upgrade_tls.ts`), turnloop owns its descriptor without exposing it
(`Detached` has no fd accessor in alpha.2 or alpha.3), and a socket's transport
is fixed at creation — so the whole class stays where the upgrade works rather
than breaking it. Local sockets move because the upgrade already refuses them.
A `worker_threads` agent also keeps tokio: it has no loop until P3/P4.

Two Node behaviours needed explicit handling that the tokio task got from its
structure. The post-EOF `end()` is submitted after the pump has fired `'end'`,
and the close waits for that shutdown's completion rather than following it
immediately — `Loop::close` cancels outstanding operations, so closing straight
away would discard a `socket.write()` issued from the `'end'` handler. And a
non-terminal accept error leaves the listener running, which is what the tokio
accept loop did on purpose and what Node does.

Completions carry a `terminal` flag for that second case, so the ABI revision
is 2; both sides compute a layout digest from their own struct definition and
registration is refused if they disagree.
… the P1 tests

Two fixes and the gap coverage for the P1 transport.

`'close'` must follow `'error'`, which the tokio task did by pushing both before
breaking its loop. The first draft used one "terminal" flag for both, so an
error suppressed the close that should have come after it. There are now two
flags: one that keeps a socket to a single `'error'` (the tokio loop's shape),
and one that keeps `'close'` to a single emission — never suppressing it.

`test-files/test_gap_turnloop_net_sockets.ts` covers the moved surface against
the Node oracle: a TCP listener with an ephemeral port, an accepted connection
echoing back, half-close, a Unix-domain socket round trip, a refused connect's
`code`/`syscall`, and a queued-write workload. It prints no port, path or errno,
because those are host-specific and asserting them would make the test about the
platform rather than the behaviour — errno in particular is 61 on darwin and
111 on linux for the same ECONNREFUSED.

Unix-domain sockets had no end-to-end coverage in the repository at all before
this (the only mention was a `net._normalizeArgs` string check), which is why
the UDS half is in the same gap test rather than waiting for its own.
…has run

turnloop can deliver a request and its FIN inside the first turn after accept,
and `server_state` may defer a loopback `ServerConnection` across a pump
boundary on purpose — so an 'end' pushed at EOF time reached a socket that had
no listeners yet and was dropped. `test_gap_turnloop_net_sockets` hung on it:
the client waited for a 'close' that never came, because the server socket's
readable-EOF never triggered its auto-end.

The EOF is now held in the same shape the tokio task used, which blocked its
post-EOF drain on the ServerConnectionReady marker, and released when that
marker arrives — the point at which the accepted socket's listeners exist.
Data already had this treatment (`buffer_pending_server_data`); end did not,
because the tokio transport never produced one that early.
P0's park picked one wait: the tokio tick while tokio owned native work,
otherwise a turnloop turn. P1 creates the case that choice cannot cover — a
tokio-owned socket and a turnloop-owned socket live in the same process, which
is the normal shape now that `net.connect` clients stay on tokio for
`upgradeToTLS` while listeners and accepted connections do not.

A full-budget tokio tick then never returns to collect a turnloop completion,
and since that completion is what would have produced the notify that ends the
tick, the two transports deadlock rather than merely delay each other. A
turnloop-backed server answering a Perry client hung after 'end'.

While both are live the tick takes a one-millisecond slice and the loop is
turned immediately after, so neither transport waits on the other for longer
than that. When only one is live nothing changes: a turnloop-only program still
blocks to its exact deadline in one turn, and a tokio-only program still gets
the full-budget tick.

One millisecond is the pre-P0 loop's own floor, so a mixed program is no
coarser than Perry was before this work; the proper bridge is to register
turnloop's Integration::Fd / Integration::Event inside the tick so it ends on
readiness instead of on a timer, and P2-P7 remove the second loop entirely.
`err.code` was derived by string-matching the message and `err.errno` /
`err.syscall` did not exist at all — `test_gap_turnloop_net_sockets` caught it
on a refused connect, where Node reports all three.

Socket error messages now carry libuv's shape (`connect ECONNREFUSED
127.0.0.1:34567`) on every path, including the tokio connect that P1 did not
move, and `build_error_object` parses that one string into `code`, `syscall`
and `errno`. The number comes from the runtime's own OS-code table through two
new ABI helpers rather than a second copy in the binding, which is how `code`
and `errno` would otherwise end up describing different failures on different
platforms — ECONNREFUSED is 61 on darwin and 111 on linux. The table's two
directions are tested against each other.

The properties are only set when the message really came from a syscall;
leaving them undefined otherwise is what Node does, so a TLS or validation
error is unchanged.
The node-suite net corpus is partly red at baseline, so it was run against a
baseline built from this branch's own pre-migration commit rather than read from
one arm: 16 pass / 27 fail / 4 crash before, 17 / 26 / 4 after, with exactly one
row changing status (connection/data-roundtrip, fail to pass) and the same four
fixtures crashing in both.

One of those crashes is attributed: an accepted socket's localAddress /
remoteAddress are undefined, and the same probe returns the same undefined on
the baseline, so it predates this work. The turnloop path does record those
endpoints — the runtime unit tests assert it on both backends.

Also records that the P0 branch does not compile on Linux at all: turnloop
alpha.2's exact libc =0.2.175 pin predates backtrace_symbols_fd, which two
runtime files call. The alpha.3 bump in this branch fixes it.
…er gate

AUX is the per-socket state the turnloop transport needs and SocketState has no
field for. Rule S fires on its i64s; every one is a handle-band id used to look
a record up in the socket or server registry, never a heap address. No JS value
reaches the map at all -- read bytes are copied into a Bytes before the sink
returns and write bytes were already owned Vecs -- so there is nothing in it for
the collector.

Deleting perry-ext-http's HTTP_PENDING_EVENTS entry is forced rather than
chosen: adding crates/perry-ext-net/src/turnloop_io.rs flips that holder from
UNCOVERED to COVERED, and an entry that no longer matches an uncovered holder
fails the gate. The cause is name resolution -- turnloop_io.rs calls push_event,
which is also the name of an ext-http function that mentions the holder, and the
walk resolves names across crates -- not a new scanner. Confirmed by removing
only that file and re-running. The holder's own verdict is unchanged; what is
lost is the record of it, and the report says so.
P1 moved `node:net`'s sockets onto turnloop. P2 starts on what the
migration audit calls "many ad-hoc threads": every remaining thread whose
only job is to turn a blocking syscall into a queue push plus a
`js_notify_main_thread()`.

The core is `crates/perry-runtime/src/turnloop_proc/`. Unlike P1 it needs
no C ABI — every P2 subsystem is compiled into perry-runtime — so a
completion is routed to its owner through an enum and a `match` rather
than through registered `extern "C"` sinks. One token space, disjoint
from P1's by construction: classes 0x10..0x1F here against P1's 1..7,
and `agent_loop::dispatch_staged` routes on exactly that range test.

P2 *adopts* descriptors rather than re-creating them. A dgram socket
carries Node's bind-time SO_REUSEADDR/SO_REUSEPORT and IPV6_V6ONLY
decisions and, afterwards, its multicast membership and interface state;
all of that stays in `dgram/net.rs` where it already worked. What moves
is the wait: a duplicate of the descriptor is attached to the agent's
loop with `Detached::from_fd` / `from_socket`, and the per-socket
`recv_from` thread — which blocked with a 250 ms read timeout purely so
it could poll a `closing` flag, and which `close()` had to unblock by
sending the socket an empty datagram before joining it — is gone.

Sends moved too, and not as an optimisation. `dup(2)` shares one open
file description, so the `O_NONBLOCK` turnloop sets on the copy it
adopted is visible through the copy Perry retains; a `send_to` there
would have started failing with EWOULDBLOCK the moment the socket buffer
filled, where it used to block. Queuing the datagram on the driver is
both correct and closer to Node, whose `send()` is asynchronous.

Received datagrams still land on the same queue and are still drained by
the same `pump` from `js_run_stdlib_pump`, so the tick a 'message' fires
on, the AsyncLocalStorage context it restores and per-socket ordering are
unchanged. The thread path survives for an agent with no loop — the P1
coexistence rule.

GC: no JS heap memory reaches the driver. Reads are copied out of the
pooled lease inside dispatch, on the owning thread; sends hand over an
owned `Vec<u8>`. What is new is that a `send(msg, cb)` whose completion
has not arrived holds `cb` in the reactor's registry, so the existing
`scan_roots_mut` now roots pending send callbacks as well as the socket
and its bind-time context — rooted from submit to completion, released
exactly once (DESIGN D3/D4).

Ten acceptance tests on real descriptors and the real driver: a UDP round
trip asserting payload and source endpoint, a receive proven to rearm
across three datagrams (turnloop's UDP receive is single-shot, so that is
this module's property, not the driver's), ordered sends draining the
queued count, an oversized datagram's EMSGSIZE reaching the submitting
token, a pipe streaming to EOF, exactly-once close, a refused submission
for an unknown id, the two token spaces proven disjoint, and — the
assumption the whole dgram design rests on — that setsockopt and
getsockname through the retained duplicate act on the same socket the
driver is receiving on.
`process.on('SIGINT', ...)` installed a `sigaction` whose handler wrote
one byte to a self-pipe, and started a `perry-signal-wake` thread whose
whole existence was to block in `read(2)` on the other end and call
`js_notify_main_thread()`. The thread was started unconditionally by the
first signal listener of any kind.

Where turnloop has a portable name for the signal, its own process-wide
dispatcher now fans the signal out to this agent's loop and the
completion lands on the thread that owns the JS heap, where it bumps the
very same `pending` counter the handler bumped. Everything downstream —
`take_pending_process_signals`, `js_process_signal_drain`, the
listener-count re-sync, the exit-code mapping — is untouched, because the
only thing that changed is who produces the wake.

Not every signal can move, and the ones that cannot are the reason the
old path is still here rather than deleted. turnloop's portable `Signal`
covers Int/Term/Hup/Usr1/Usr2; Perry also offers SIGQUIT, SIGABRT, SIGBUS
and SIGPIPE, and SIGABRT/SIGBUS in particular are co-owned by the GC
quarantine reporter, so silently dropping them was never an option. Those
keep `sigaction` — and the wake thread now starts only if one of them is
actually subscribed, so a program that handles SIGINT and SIGTERM, which
is every CLI with a graceful shutdown, starts no thread at all.

The subscription is created unref'd. A registered signal listener is
ref-neutral (`has_active_process_signal_listeners` gates on pending > 0,
not on listeners > 0; `crates/perry/tests/issue_signal_listener_ref_neutral.rs`
is the regression test), and unreffing the handle encodes that in the
transport instead of leaving a second counter to undo it.

Which slots took which path is a bitmask rather than nine more statics,
because uninstall has to unwind the transport that installed it: a
turnloop subscription is stopped through the driver, which restores the
previous disposition, and the bit is cleared by the terminal completion
rather than at the call, so the unwind stays exactly-once.
Every spawned child started a reader thread per readable pipe: two for
piped stdout/stderr, plus one for each extra `stdio` descriptor. Each
thread blocked in `read`, pushed the bytes onto the shared event queue
and woke the loop. They are now multishot reads on the agent's loop,
delivering the *same* `CpEvent::Data` / `CpEvent::Eof` on the thread that
owns the JS heap, so `cp_reactor_pump` and every event-ordering rule it
implements — the stdout-EOF-held-until-stderr-EOF rule included — are
untouched.

The translation is deliberately literal: the deleted loop body treated
`Ok(0)` and `Err(_)` identically, so a terminal read failure becomes EOF
here too, and a non-terminal one is ignored rather than being reported as
a stream error Node does not have.

Adoption moves the descriptor, so the fallback for an agent with no loop
reconstructs its blocking reader from that descriptor rather than from a
second copy: there is exactly one owner at every instant, and a failed
adoption reports EOF instead of pretending a closed pipe is still
readable.

Ownership at the far end needed care in the other direction. A thread
dropped its pipe at EOF; a loop entry does not, so the entry is closed at
EOF and any entry a child still holds is released before its registry row
goes — otherwise a program spawning children in a loop accumulates
descriptors the driver is still holding.

`reactor.rs` crossed the 2000-line cap, so the pipe code is in
`reactor/streams.rs`. It is a pure move plus the new code; the reader
call sites stay where they were.

Also adds the P2 half of the `PERRY_LOOP_STATS=1` exit line. The existing
`completions=` cannot distinguish a socket P1 carried from a child pipe
P2 carried, and every live count is zero by the time a process exits, so
the line reports the lifetime adoption count alongside the live one —
which is what an A/B or an acceptance test reads to know the threads were
really replaced rather than merely not used.
Ralph Küpper and others added 13 commits September 16, 2026 13:51
Closes the 2,048-connection ceiling (#10351). The old number was not a tuning
choice but a consequence of turnloop allocating max_handles up front; alpha.5
pages the slot tables, so a loop's idle cost is one page and identical from 1K
to 1M handles, and only the high-water mark costs anything.
turnloop 0.1.0-alpha.5 adds ListenOpts::accept_defaults -- options the
accepting loop applies to every accepted socket before the Accepted completion
reaches the host. Both of Perry's listener sites built the struct literally and
stopped compiling.

Filling the rest from Default rather than naming the new field keeps the next
added field from breaking the build again, and says what Perry wants: it sets
per-socket options from JS after accept (setNoDelay), so the listener imposes
no defaults and a socket keeps what the OS gave it until JS says otherwise,
which is Node's behaviour.
…dle ceiling

Raising max_operations to 131_072 alongside the handle ceiling cost 19 MB of
resident memory per net loop -- measured, idle RSS 41 MB -> 60 MB -- which is
not what 'the ceiling is free' was supposed to mean.

max_operations sizes two structures with completely different needs. The
table is paged since turnloop#75 and costs nothing at any ceiling. The blocking
WorkPort's queue is a lock-free ring, deliberately left contiguous because its
capacity IS its backpressure bound, so it is allocated in full: 131_072 slots
is ~19 MB for a ring that a server holding idle connections never fills.

32_768 covers one armed read per connection at the 65_536-handle ceiling.
The tokio inventory recorded this edge as immovable: "`ksni` and
`mpris-server` REQUIRE tokio (they are zbus clients with a `tokio`
feature). Removing it means replacing both crates or dropping tray/MPRIS
support on Linux." Neither half is true.

`ksni` 0.3 has an `async-io` feature that is a peer of its `tokio`
default -- the two are mutually exclusive (`ksni::compat` refuses to
compile with both), and in `async-io` mode ksni carries its own
`async_executor` driver thread and builds its zbus connection with
`internal_executor(false)`, so it needs no ambient runtime. And
`mpris-server`'s `tokio` feature is opt-in, absent from its defaults,
and only forwards to `zbus/tokio`; without it zbus runs on `async-io`
and starts its own `zbus::Connection executor` thread per connection.
Perry had asked for both features and then kept a direct tokio
dependency to feed them -- three uses in two files.

Those become `background.rs`: `futures-lite`'s `block_on` for the two
one-shot handshakes (`ksni`'s `spawn()`, `mpris_server::Server::new()`),
and one lazily started `async-executor` thread for the tray's
fire-and-forget property refreshes, which must not park the GTK main
thread and must still be delivered before `app.run()` (so glib's
`MainContext::spawn_local` is not a substitute). The MPRIS update queue
moves to `async-channel`. Every piece of work stays on the kind of
thread it was on; nothing moved onto or off the GTK main thread.

`zbus` consequently compiles without its `tokio` feature for the whole
workspace, which also drops `tokio/tracing` -- zbus was its only
enabler and nothing in the tree consumes the instrumentation. One new
transitive crate, `task-local` 0.1.1 (MIT OR Apache-2.0).

The inventory goes 39 edges -> 38 with group M empty, and the wrong
blocker text is corrected rather than dropped in p8-report.md.

No CI tier builds this crate (the runners have no libgtk-4-dev), so it
ships with a headless `dbus-run-session` test that claims the tray's
StatusNotifierItem name and reads its Id back, proves a `set_tooltip`
reaches the bus as a `NewToolTip` signal -- the properties are computed
on demand, so only the signal shows the fire-and-forget update ran --
brings up the MPRIS object and checks the pushed title arrives in a
`PropertiesChanged`, and asserts no tokio worker thread exists.
…de 26.5.1

Perry's `node:http2` control surface is a loopback simulation.
`session.settings()`, `.ping()` and `.goaway()` in
perry-ext-http `server/http2_server/controls.rs` never encode a frame: they
walk the process's own handle table with
`iter_handle_ids_of::<Http2SessionHandle>` for a session of the opposite kind
and push a synthetic event at it. `test-parity/node-suite/http2/` passes
because both ends of every fixture there are Perry, in one process, so the
scan always finds its "peer". Against a real peer none of those three methods
does anything.

Fourteen new gap fixtures pin what the surface has to do, recorded from Node
26.5.1 and never from Perry's output. Every fixture named `_wire_` puts a raw
TCP socket on one end and hand-encodes/decodes HTTP/2 frames
(`test-files/_helpers/h2_wire.ts`), which the loopback path cannot satisfy:
there is no second session handle to find. The codec ships an HPACK encoder
only (literal-without-indexing, no Huffman) — enough to open real streams —
because every assertion reads control frames, which carry no header block.

Established, all oracle-verified and byte-identical across three Linux runs and
one macOS run:

- SETTINGS: a default server's first frame is EMPTY; configured keys serialise
  in ascending identifier order; `pendingSettingsAck` is true from connect; one
  ACK resolves one outstanding SETTINGS, in order, and `'localSettings'` plus
  the user callback fire only then — with three arguments,
  `(err, settings, duration)`.
- PING: the round-trip duration is a real positive measurement; `ping()`
  without a callback throws ERR_INVALID_ARG_TYPE; an unsolicited PING|ACK is a
  protocol error.
- GOAWAY: `'goaway'`'s third argument is `undefined` when the frame carried no
  opaque data, not a zero-length Buffer; sending one does not close the
  session; from a client session an ODD lastStreamID — 2147483647 included — is
  dropped silently.
- Two corrections to widely-held belief, both measured: a stream opened after a
  graceful GOAWAY gets NO frame at all from Node, not
  RST_STREAM(REFUSED_STREAM); and REFUSED_STREAM's real trigger,
  maxConcurrentStreams, has two regimes — RST_STREAM code=7 before the peer
  ACKs the limit, GOAWAY code=2 / errno -505 after.
- Flow control, the protocol-error to GOAWAY-code table (including nghttp2's
  "DATA: stream_id == 0" debug string), close() vs destroy(), ALPN on
  createSecureServer, and cross-session isolation of every control frame.

All fourteen fail against Perry today — 9 parity_fail, 5 crash — which is the
intended state. They are registered in `test-parity/gap_snapshot.json` and
`test-parity/known_failures.json` at that status, so the transport work flips
them and the snapshot diff is the record of it.

Companion document `docs/src/testing/http2-conformance.md` lists every
behaviour with Node's actual output beside it and the ordered list of what the
transport must implement. Lane report: `docs/turnloop/h2c-report.md`.

No transport code is changed here.
…s, and emit 'error' on a failed listen

turnloop_serve::listen passed server.noDelay into perry_ffi::turnloop_net::
tcp_listen's SIXTH parameter, which is reuse_port. The value was not dropped:
it travels perry-ffi -> abi.rs -> turnloop_net::tcp_listen -> turnloop's
ListenOpts { reuse_port } -> SO_REUSEPORT. noDelay defaults to true (Node's
http.createServer default since v16.5.0), so since P5 every turnloop HTTP and
HTTPS listener has had two things wrong at once:

  * SO_REUSEPORT was set on every server listener, so a second listen() on a
    port another Perry server already held silently succeeded where Node
    answers EADDRINUSE. Nothing on this path wanted it: the cluster worker
    that does declines the turnloop path in try_listen_on_turnloop and binds a
    std::net::TcpListener. perry-ext-net's own tcp_listen call always passed
    false; only this one drifted.
  * TCP_NODELAY was never applied, leaving the turnloop transport as the only
    one serving HTTP with Nagle on -- the hyper path sets it by hand on every
    accepted stream. no_delay now reaches ListenOpts::accept_defaults, which
    turnloop applies to every accepted socket before its completion reaches
    the binding, which is where Node applies a server-level noDelay.

A failed bind now emits 'error' instead of printing to stderr. That is a
second, independent defect -- reproduced on the base commit with the port held
by a non-Perry process, so SO_REUSEPORT was not masking it -- and it is why the
first fix cannot land alone: with the bind correctly failing and nothing
reaching JS, a program waiting on server.on('error') hangs instead of merely
getting the wrong answer. Both try_listen_on_turnloop and the hyper
bind_listener arm now queue a ListenError that the existing deferred-event pump
drains as 'error', asynchronously, with this bound to the server. Measured on
Node 26.5.1: order is after-listen-call,error; 'listening' never fires; the
listen(cb) callback never runs; server.listening stays false. The payload
carries message, code, errno, syscall, address, port and name.

Evidence, base 1db2f76 vs this branch, Node 26.5.1 oracle:

  base  second: listening port-matches=true   (three servers, one port)
        [error fixture] exit=124 -- the program HUNG
  fix   both fixtures byte-identical to node --experimental-strip-types,
        with driver=turnloop in PERRY_LOOP_STATS

Full gap suite, both arms, 6 shards, PERRY_NO_AUTO_OPTIMIZE=1 on each:
819 tests base / 821 fix, PARITY_FAIL sets identical (the same 9), both new
fixtures PASS, zero Perry-attributable status changes. The single difference,
test_gap_9536_fetch_url_error NODE_FAIL -> PASS, is a DNS flake in the ORACLE
on the base run (example.invalid); it re-runs PASS on the base arm.

Pinned by test_gap_turnloop_listen_conflict.ts, test_gap_turnloop_listen_error.ts,
three listen_error_tests, and turnloop_net::tests::
listen_opts_put_each_argument_in_its_own_field over a new pure listen_opts()
seam -- the test that would have caught this, since neither symptom is visible
at the call site and both live in options handed to the OS.
`build_listen_error_value` allocates its object, then binds `message` (a
Rust String from `format!`, no JS allocation), then null-checks, then roots.
The checker flags `obj` as live-and-unrooted across the `alloc_string` at
:288 -- but that call is inside the `obj.is_null()` branch, where `obj` is
null and the function returns a bare string. On the non-null path nothing
between the allocation and `root_nanbox` can collect.

So this is the checker being conservative about a branch it cannot rule out,
not a rooting hazard. Recorded rather than restructured: rooting a possibly
null pointer to satisfy a static reader would be worse code, and the entry is
deleted by whoever changes that function's shape.
…#10385)

Validating the #10385 Windows work order against `turnloop/integration` found
the platform unbuildable, for three stacked reasons — each hidden by the one in
front of it, and all invisible to CI because `windows-build` dies on a UI crate
before it ever attempts a link.

One turnloop bug:

- `child_process/reactor.rs` — P2 moved `cp_register_live_child_parts` to
  `Option<CpPipe>` so the loop can adopt a child pipe's descriptor, but only
  the cross-platform caller was migrated. The `#[cfg(windows)]` fork path still
  passed a pre-boxed reader. A pure cfg-divergence, unconditional on Windows.

Six pre-existing ones:

- Windows had no precise GC roots at all (#7354). LLVM's
  rewrite-statepoints-for-gc crashes on WinEH funclets, so every module with a
  `try` refused to compile under the target-aware default — and essentially all
  async lowers to a `try`. Windows now uses the same invoke/landingpad shape as
  ELF/Mach-O with Perry's own personality, which makes LLVM emit
  `.seh_handler perry_eh_personality` plus an Itanium `GCC_except_table` on
  COFF; a new x64 language handler walks that LSDA and transfers via
  `RtlUnwindEx`. Verified by `.pgcmap` presence under RS4GC and absence without.
- `class X extends LRUCache` (#10293) could not link on Windows at all: the
  consumer was compiled unconditionally while its provider is feature-gated, and
  MSVC pulls the whole object. Now behind a `lru-subclass` feature.
- `err.errno` reported the negated OS code on Windows at three sites, where
  libuv uses its own -4xxx space (-4091, not -10048).
- `perry-ui-windows-winui` was missing `reorder_child`, the E0425 that makes
  `windows-build` red.
- Two perry-runtime GC tests failed for reasons unrelated to the runtime — one
  asserting a `#[cfg(debug_assertions)]` outcome under a release-inheriting
  profile, one keyed on a platform list that predates the mimalloc purge path.
- `node_compat_matrix.mjs` could not run on Windows (no `.exe` on PERRY_BIN) and
  then reported working modules as broken (no `.exe` on the probe output).

Validation on Windows: `test_gap_turnloop_*` 15/16 pass against Node 26.5.1
(from 16/16 refusing to compile), `perry-runtime` 3942/0, and §3's ceiling
measured — 10,000/10,000 connections, with ~36 MB of idle cost attributed to
the eagerly-committed IOCP operation slab. `max_operations` is deliberately not
lowered; that would reinstate the #10351 connection ceiling. Details and the
recommended upstream turnloop fix are in the changelog fragment.
…0385)

`COMPILE_TIMEOUT_MS` was 300s, under the ~4-5 min a cold auto-optimize rebuild
takes for an ext-routed module on Windows. The matrix probes the UNPREFIXED
form first, so it timed out there while the `node:`-prefixed form reused the
now-warm cache and succeeded — and that asymmetry is reported as a PREFIX
DIVERGENCE, which the harness's own docs call "a real Perry bug".

Measured: an identical cold vs warm pair of runs reported 14 vs 4 prefix
divergences and 10 vs 0 unresolved modules. The ten extras (crypto, net, tls,
zlib, http, http2, assert, events, fs/promises, vm) are all ext-routed and all
artefacts. CI runs cold, so this was aimed squarely at it.

Raised to 900s, and the first Windows measurement of the builtin surface
recorded in the changelog fragment: 58 probed, 23 both-forms match, 33
shape-diff, 0 unresolved, 4 genuine prefix divergences (sea, sqlite, test,
test/reporters — reproduced cold and warm, none ext-routed).
835 fixtures against Node 26.5.1: 784 pass / 38 parity-fail / 7 compile-fail /
5 crash, 94.8%.

None of the seven compile failures is real. They are the auto-optimize
cache-coherence defect now written up in the fragment: an archive is kept on
`auto_optimized_source_fingerprint` but validated at link against
perry-runtime's build stamp, so a one-commit-stale archive is judged fresh,
reused, and then fatally rejected. Cleared `target/perry-auto-*` and re-ran:
six pass outright (3527_http_ctor_prototype, 3662_node_argvalidation, and the
6316/6326/6336/6343 native-base cluster — that slice is 21/21) and
constants_tail_3683plus builds but diverges on output. Corrected:
790 pass / 39 parity-fail / 0 compile-fail / 5 crash.

All five crashes are test_gap_http2_wire_*, all 10s timeouts rather than memory
faults, in the fixture set #10385 pre-declares an expected-fail ratchet.

Also records what was NOT done: the two-arm A/B against the merge base, which
needs a second full build the box could not hold — a full ext-routed gap run
transiently wants ~20 GB of auto-optimize scratch. The 39 parity failures are
therefore untriaged as to whether the migration caused them.
@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 332 files, which is 32 over the limit of 300.

To get a review, reduce the PR to 300 files or fewer by splitting it into smaller PRs or changing its base branch.

Usage-priced reviews support at most 300 files.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 1385cc2d-e529-4e84-a68b-91cbc77cfe12

📥 Commits

Reviewing files that changed from the base of the PR and between b77aba6 and b3adf5f.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (332)
  • .github/workflows/ext-link.yml
  • .github/workflows/test.yml
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/10354-http2-conformance-fixtures.md
  • changelog.d/10354-turnloop-p9-agent-loops.md
  • changelog.d/10385-windows-support.md
  • changelog.d/p5-listen-fix.md
  • changelog.d/turnloop-gtk4-detokio.md
  • changelog.d/turnloop-p0-wait-driver.md
  • changelog.d/turnloop-p0-wait-metrics.md
  • changelog.d/turnloop-p1-net-sockets.md
  • changelog.d/turnloop-p11-cli-clients.md
  • changelog.d/turnloop-p2-process.md
  • changelog.d/turnloop-p3-timers.md
  • changelog.d/turnloop-p4-pool.md
  • changelog.d/turnloop-p5-servers.md
  • changelog.d/turnloop-p6-clients.md
  • changelog.d/turnloop-p7-databases.md
  • changelog.d/turnloop-p8-tokio-inventory.md
  • changelog.d/turnloop-raise-handle-ceiling.md
  • changelog.d/turnloop-worker-agent-id.md
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/entry/event_loop.rs
  • crates/perry-codegen/src/codegen/entry/tests.rs
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/function.rs
  • crates/perry-codegen/src/module.rs
  • crates/perry-codegen/src/runtime_decls/strings_part2.rs
  • crates/perry-codegen/src/stmt/try_stmt.rs
  • crates/perry-db-turnloop/Cargo.toml
  • crates/perry-db-turnloop/src/lib.rs
  • crates/perry-db-turnloop/src/tests.rs
  • crates/perry-ext-argon2/src/lib.rs
  • crates/perry-ext-axios/Cargo.toml
  • crates/perry-ext-axios/src/lib.rs
  • crates/perry-ext-bcrypt/src/lib.rs
  • crates/perry-ext-fetch/Cargo.toml
  • crates/perry-ext-fetch/src/gc.rs
  • crates/perry-ext-fetch/src/lib.rs
  • crates/perry-ext-fetch/src/request_fields.rs
  • crates/perry-ext-fetch/src/test_async_shims.rs
  • crates/perry-ext-fetch/src/test_link_stubs.rs
  • crates/perry-ext-fetch/src/tests.rs
  • crates/perry-ext-fetch/src/validation.rs
  • crates/perry-ext-http/Cargo.toml
  • crates/perry-ext-http/src/server/handle_dispatch.rs
  • crates/perry-ext-http/src/server/https_server.rs
  • crates/perry-ext-http/src/server/mod.rs
  • crates/perry-ext-http/src/server/response.rs
  • crates/perry-ext-http/src/server/response_tests.rs
  • crates/perry-ext-http/src/server/response_turnloop.rs
  • crates/perry-ext-http/src/server/server.rs
  • crates/perry-ext-http/src/server/server/deferred_events.rs
  • crates/perry-ext-http/src/server/server/turnloop_listen.rs
  • crates/perry-ext-http/src/server/turnloop_serve/conn.rs
  • crates/perry-ext-http/src/server/turnloop_serve/mod.rs
  • crates/perry-ext-http/src/server/turnloop_serve/tests.rs
  • crates/perry-ext-http/src/server/turnloop_serve/wire.rs
  • crates/perry-ext-http/src/test_async_shims.rs
  • crates/perry-ext-http/src/transport_error.rs
  • crates/perry-ext-ioredis/Cargo.toml
  • crates/perry-ext-ioredis/src/lib.rs
  • crates/perry-ext-ioredis/src/test_async_shims.rs
  • crates/perry-ext-ioredis/src/turnloop_io.rs
  • crates/perry-ext-mongodb/Cargo.toml
  • crates/perry-ext-mongodb/src/lib.rs
  • crates/perry-ext-mongodb/src/test_async_shims.rs
  • crates/perry-ext-mongodb/src/turnloop_io/connection.rs
  • crates/perry-ext-mongodb/src/turnloop_io/mod.rs
  • crates/perry-ext-mongodb/src/turnloop_io/ops.rs
  • crates/perry-ext-mongodb/src/turnloop_io/tests.rs
  • crates/perry-ext-mysql2/Cargo.toml
  • crates/perry-ext-mysql2/src/lib.rs
  • crates/perry-ext-mysql2/src/turnloop_io/connection.rs
  • crates/perry-ext-mysql2/src/turnloop_io/convert.rs
  • crates/perry-ext-mysql2/src/turnloop_io/mod.rs
  • crates/perry-ext-mysql2/src/turnloop_io/pool.rs
  • crates/perry-ext-mysql2/src/turnloop_io/tests.rs
  • crates/perry-ext-net/Cargo.toml
  • crates/perry-ext-net/src/adopt.rs
  • crates/perry-ext-net/src/bun_tcp.rs
  • crates/perry-ext-net/src/ipc.rs
  • crates/perry-ext-net/src/jsvalue.rs
  • crates/perry-ext-net/src/lib.rs
  • crates/perry-ext-net/src/lifecycle.rs
  • crates/perry-ext-net/src/option_setters.rs
  • crates/perry-ext-net/src/raw_bridge.rs
  • crates/perry-ext-net/src/server_state.rs
  • crates/perry-ext-net/src/socket_events.rs
  • crates/perry-ext-net/src/tls.rs
  • crates/perry-ext-net/src/turnloop_io.rs
  • crates/perry-ext-net/src/turnloop_tls.rs
  • crates/perry-ext-net/src/turnloop_tls_io.rs
  • crates/perry-ext-nodemailer/src/dispatch_ext.rs
  • crates/perry-ext-nodemailer/src/lib.rs
  • crates/perry-ext-nodemailer/src/turnloop_bridge.rs
  • crates/perry-ext-pg/Cargo.toml
  • crates/perry-ext-pg/src/lib.rs
  • crates/perry-ext-pg/src/test_async_shims.rs
  • crates/perry-ext-pg/src/turnloop_io.rs
  • crates/perry-ext-pg/src/turnloop_io/result.rs
  • crates/perry-ext-sharp/src/lib.rs
  • crates/perry-ext-sharp/src/test_async_shims.rs
  • crates/perry-ffi/src/lib.rs
  • crates/perry-ffi/src/pool.rs
  • crates/perry-ffi/src/turnloop_net.rs
  • crates/perry-http-client/Cargo.toml
  • crates/perry-http-client/examples/probe.rs
  • crates/perry-http-client/examples/ws_probe.rs
  • crates/perry-http-client/src/http.rs
  • crates/perry-http-client/src/lib.rs
  • crates/perry-http-client/src/multipart.rs
  • crates/perry-http-client/src/tls.rs
  • crates/perry-http-client/src/transport.rs
  • crates/perry-http-client/src/ws/frame.rs
  • crates/perry-http-client/src/ws/mod.rs
  • crates/perry-runtime/Cargo.toml
  • crates/perry-runtime/src/agent.rs
  • crates/perry-runtime/src/child_process/reactor.rs
  • crates/perry-runtime/src/child_process/reactor/streams.rs
  • crates/perry-runtime/src/dgram.rs
  • crates/perry-runtime/src/dgram/net.rs
  • crates/perry-runtime/src/dgram_reactor.rs
  • crates/perry-runtime/src/eh.rs
  • crates/perry-runtime/src/eh_lsda.rs
  • crates/perry-runtime/src/eh_windows.rs
  • crates/perry-runtime/src/event_pump.rs
  • crates/perry-runtime/src/event_pump/agent_loop.rs
  • crates/perry-runtime/src/event_pump/agent_loop_tests.rs
  • crates/perry-runtime/src/event_pump/loop_stats.rs
  • crates/perry-runtime/src/event_pump/loop_stats_tests.rs
  • crates/perry-runtime/src/event_pump/precise_wait.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/tests/heap_generation.rs
  • crates/perry-runtime/src/gc/tests/telemetry_verifier.rs
  • crates/perry-runtime/src/lib.rs
  • crates/perry-runtime/src/node_api_host/async_work.rs
  • crates/perry-runtime/src/node_api_host/tests.rs
  • crates/perry-runtime/src/node_submodules/diagnostics.rs
  • crates/perry-runtime/src/object/global_fetch.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/os.rs
  • crates/perry-runtime/src/os/signal.rs
  • crates/perry-runtime/src/os_process_streams.rs
  • crates/perry-runtime/src/process/ipc.rs
  • crates/perry-runtime/src/promise/microtasks.rs
  • crates/perry-runtime/src/promise/microtasks/empty.rs
  • crates/perry-runtime/src/promise/native_async.rs
  • crates/perry-runtime/src/thread.rs
  • crates/perry-runtime/src/timer.rs
  • crates/perry-runtime/src/timer/async_lifecycle.rs
  • crates/perry-runtime/src/timer/drain_expired_tests.rs
  • crates/perry-runtime/src/timer/gc_scan.rs
  • crates/perry-runtime/src/timer/mock.rs
  • crates/perry-runtime/src/timer/ownership.rs
  • crates/perry-runtime/src/timer/phases.rs
  • crates/perry-runtime/src/timer/store.rs
  • crates/perry-runtime/src/timer/store_tests.rs
  • crates/perry-runtime/src/timer/test_shared_queues.rs
  • crates/perry-runtime/src/timer/tests_inline.rs
  • crates/perry-runtime/src/turnloop_net/abi.rs
  • crates/perry-runtime/src/turnloop_net/errors.rs
  • crates/perry-runtime/src/turnloop_net/mod.rs
  • crates/perry-runtime/src/turnloop_net/sink.rs
  • crates/perry-runtime/src/turnloop_net/tests.rs
  • crates/perry-runtime/src/turnloop_pool/mod.rs
  • crates/perry-runtime/src/turnloop_pool/tests.rs
  • crates/perry-runtime/src/turnloop_proc/adopt.rs
  • crates/perry-runtime/src/turnloop_proc/mod.rs
  • crates/perry-runtime/src/turnloop_proc/registry.rs
  • crates/perry-runtime/src/turnloop_proc/tests.rs
  • crates/perry-runtime/src/url/abort.rs
  • crates/perry-stdlib/Cargo.toml
  • crates/perry-stdlib/src/argon2.rs
  • crates/perry-stdlib/src/bcrypt.rs
  • crates/perry-stdlib/src/common/async_bridge.rs
  • crates/perry-stdlib/src/common/dispatch/init.rs
  • crates/perry-stdlib/src/common/dispatch/method_dispatch.rs
  • crates/perry-stdlib/src/cron.rs
  • crates/perry-stdlib/src/crypto/kdf.rs
  • crates/perry-stdlib/src/fetch/abort_bridge.rs
  • crates/perry-stdlib/src/fetch/mod.rs
  • crates/perry-stdlib/src/fetch/transport_error.rs
  • crates/perry-stdlib/src/fetch/turnloop_bridge.rs
  • crates/perry-stdlib/src/framework/server.rs
  • crates/perry-stdlib/src/lib.rs
  • crates/perry-stdlib/src/nodemailer.rs
  • crates/perry-stdlib/src/nodemailer/turnloop_bridge.rs
  • crates/perry-stdlib/src/perry_ffi_async.rs
  • crates/perry-stdlib/src/readline/pump.rs
  • crates/perry-stdlib/src/tls.rs
  • crates/perry-stdlib/src/tls/event_pump.rs
  • crates/perry-stdlib/src/tls/liveness.rs
  • crates/perry-stdlib/src/tls/liveness_tests.rs
  • crates/perry-stdlib/src/turnloop_client/exchange.rs
  • crates/perry-stdlib/src/turnloop_client/mod.rs
  • crates/perry-stdlib/src/turnloop_client/tests.rs
  • crates/perry-stdlib/src/turnloop_smtp/ffi.rs
  • crates/perry-stdlib/src/turnloop_smtp/mod.rs
  • crates/perry-stdlib/src/turnloop_smtp/tests.rs
  • crates/perry-stdlib/src/turnloop_tls_client.rs
  • crates/perry-stdlib/src/worker_threads.rs
  • crates/perry-stdlib/src/worker_threads/worker_pump.rs
  • crates/perry-stdlib/src/zlib.rs
  • crates/perry-tls-session/Cargo.toml
  • crates/perry-tls-session/src/lib.rs
  • crates/perry-ui-gtk4/Cargo.toml
  • crates/perry-ui-gtk4/src/background.rs
  • crates/perry-ui-gtk4/src/lib.rs
  • crates/perry-ui-gtk4/src/media_playback.rs
  • crates/perry-ui-gtk4/src/tray.rs
  • crates/perry-ui-gtk4/tests/dbus_services_without_tokio.rs
  • crates/perry-ui-windows-winui/src/widgets.rs
  • crates/perry/Cargo.toml
  • crates/perry/src/commands/audit.rs
  • crates/perry/src/commands/compile/optimized_libs/driver.rs
  • crates/perry/src/commands/compile/optimized_libs/freshness.rs
  • crates/perry/src/commands/login.rs
  • crates/perry/src/commands/publish/mod.rs
  • crates/perry/src/commands/publish/preflight.rs
  • crates/perry/src/commands/publish/server_api.rs
  • crates/perry/src/commands/run/mod.rs
  • crates/perry/src/commands/run/remote.rs
  • crates/perry/src/commands/run/resign.rs
  • crates/perry/src/commands/setup/ios.rs
  • crates/perry/src/commands/setup/macos.rs
  • crates/perry/src/commands/verify.rs
  • crates/perry/src/compat_reports.rs
  • crates/perry/src/main.rs
  • crates/perry/src/telemetry.rs
  • crates/perry/src/update_checker.rs
  • crates/perry/well_known_bindings.toml
  • docs/src/SUMMARY.md
  • docs/src/native-libraries/governance.md
  • docs/src/testing/http2-conformance.md
  • docs/turnloop/gtk4-report.md
  • docs/turnloop/h2c-report.md
  • docs/turnloop/p0-report.md
  • docs/turnloop/p1-report.md
  • docs/turnloop/p11-report.md
  • docs/turnloop/p2-report.md
  • docs/turnloop/p3-report.md
  • docs/turnloop/p4-report.md
  • docs/turnloop/p5-report.md
  • docs/turnloop/p6-report.md
  • docs/turnloop/p7-report.md
  • docs/turnloop/p8-report.md
  • docs/turnloop/p9-report.md
  • scripts/gc_runtime_root_holders.json
  • scripts/node_compat_matrix.mjs
  • scripts/raw_handle_debt_baseline.txt
  • scripts/raw_handle_debt_files.txt
  • scripts/tokio_inventory.json
  • scripts/tokio_inventory.py
  • scripts/turnloop/apps/_helpers/p9_message_fanin_worker.ts
  • scripts/turnloop/apps/_helpers/p9_rss_worker.ts
  • scripts/turnloop/apps/_helpers/p9_servers.mjs
  • scripts/turnloop/apps/_helpers/p9_socket_concurrency_worker.ts
  • scripts/turnloop/apps/_helpers/p9_worker_acceptance_worker.ts
  • scripts/turnloop/apps/_helpers/p9_worker_gc_stress_worker.ts
  • scripts/turnloop/apps/_helpers/tokio_worker_agent_census_worker.ts
  • scripts/turnloop/apps/db_thread_census.ts
  • scripts/turnloop/apps/mongo_parity.ts
  • scripts/turnloop/apps/mysql_parity.ts
  • scripts/turnloop/apps/mysql_pool_parity.ts
  • scripts/turnloop/apps/node_http_hello.ts
  • scripts/turnloop/apps/p6_abort_midbody.ts
  • scripts/turnloop/apps/p6_tls_remote.ts
  • scripts/turnloop/apps/p9_agent_loop_rss.ts
  • scripts/turnloop/apps/p9_socket_concurrency.ts
  • scripts/turnloop/apps/p9_thread_agent.ts
  • scripts/turnloop/apps/p9_thread_agent_async_refused.ts
  • scripts/turnloop/apps/p9_worker_agent_acceptance.ts
  • scripts/turnloop/apps/p9_worker_gc_stress.ts
  • scripts/turnloop/apps/p9_worker_message_fanin.ts
  • scripts/turnloop/apps/p9_worker_socket_to_primary.ts
  • scripts/turnloop/apps/pg_parity.ts
  • scripts/turnloop/apps/pg_thread_census.ts
  • scripts/turnloop/apps/pool_gc_stress.ts
  • scripts/turnloop/apps/pool_thread_census.ts
  • scripts/turnloop/apps/redis_gc_stress.ts
  • scripts/turnloop/apps/redis_parity.ts
  • scripts/turnloop/apps/timer_loop_stats.ts
  • scripts/turnloop/apps/tokio_worker_agent_census.ts
  • scripts/turnloop/server_ab.py
  • scripts/turnloop_p0_loop_stats.py
  • scripts/turnloop_p0_native_probe.py
  • scripts/unrooted_local_shape_baseline.json
  • test-files/_helpers/h2_wire.ts
  • test-files/_helpers/turnloop_p9_worker_net.ts
  • test-files/test_gap_http2_alpn_secure.ts
  • test-files/test_gap_http2_e2e_streams.ts
  • test-files/test_gap_http2_session_isolation.ts
  • test-files/test_gap_http2_wire_client_ping.ts
  • test-files/test_gap_http2_wire_flow_control.ts
  • test-files/test_gap_http2_wire_frame_errors.ts
  • test-files/test_gap_http2_wire_goaway_recv.ts
  • test-files/test_gap_http2_wire_goaway_send.ts
  • test-files/test_gap_http2_wire_goaway_server.ts
  • test-files/test_gap_http2_wire_refused_stream.ts
  • test-files/test_gap_http2_wire_server_ping.ts
  • test-files/test_gap_http2_wire_server_settings.ts
  • test-files/test_gap_http2_wire_session_lifecycle.ts
  • test-files/test_gap_http2_wire_settings_ack.ts
  • test-files/test_gap_turnloop_fetch.ts
  • test-files/test_gap_turnloop_fetch_pool_wait.ts
  • test-files/test_gap_turnloop_http_lifecycle.ts
  • test-files/test_gap_turnloop_http_server.ts
  • test-files/test_gap_turnloop_https_server.ts
  • test-files/test_gap_turnloop_keepalive_timeout.ts
  • test-files/test_gap_turnloop_listen_conflict.ts
  • test-files/test_gap_turnloop_listen_error.ts
  • test-files/test_gap_turnloop_net_sockets.ts
  • test-files/test_gap_turnloop_p0_timers.ts
  • test-files/test_gap_turnloop_p2_process.ts
  • test-files/test_gap_turnloop_p3_io_phase_order.ts
  • test-files/test_gap_turnloop_p3_phase_order.ts
  • test-files/test_gap_turnloop_p3_timer_heap.ts
  • test-files/test_gap_turnloop_p4_pool.ts
  • test-files/test_gap_turnloop_p9_worker_agent_net.ts
  • test-files/test_turnloop_p0_deadline_05.ts
  • test-files/test_turnloop_p0_deadline_10.ts
  • test-files/test_turnloop_p0_deadline_2.ts
  • test-files/test_turnloop_p0_idle.ts
  • test-files/test_turnloop_p0_interval.ts
  • test-files/test_turnloop_p0_promise_churn.ts
  • test-files/test_turnloop_p0_submillisecond_remainder.ts
  • test-files/test_turnloop_p6_smtp.ts
  • test-parity/gap_snapshot.json
  • test-parity/known_failures.json
  • workspace-architecture.json

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

`is_native_module` strips `node:` unconditionally, which is right for every
builtin except the four that postdate the prefix convention. Node deliberately
does NOT expose `sea`, `sqlite`, `test` or `test/reporters` under their bare
names, because those are live npm package names — measured on the pinned oracle
(26.5.1): none appears in `module.builtinModules`, `require('test')` and
`require('sqlite')` raise MODULE_NOT_FOUND, and only `require('node:test')`
resolves.

Perry claimed all of them, so a project with a `test` or `sqlite` dependency got
Perry's builtin instead of the package it asked for.

Found by the Node compat matrix on Windows, which flagged exactly these four as
`perry-extra` — resolved by Perry, unresolved by Node. That category only became
observable once the matrix could run there at all (also #10385).

An explicit `externals` entry still wins: that is the caller naming the module
on purpose.

The test gates on `NATIVE_MODULES` membership rather than asserting all four
blindly — `test/reporters` is not in the manifest today, so asserting it would
assert the wrong thing — with a floor so it cannot pass vacuously if the
manifest stops claiming them. perry-hir: 419 passed, 0 failed.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit note from the merge queue — this PR cannot land on main as it stands, and the reason is structural, not a problem with the work.

Its base is turnloop/integration, whose merge-base with main is fcd108bfb0 (v0.5.1579) — 375 commits back. That branch is a draft and is parked. So there is no path from here into a merge train.

That would be a waste, because by your own accounting nine of the ten defects are pre-existing on main, and the diff separates cleanly along a subsystem line. One turnloop-owned item (#1, the cfg(windows) fork path / CpPipe) plus three turnloop files — turnloop_net/abi.rs, turnloop_net/errors.rs, event_pump/agent_loop.rs — are the only parts that need that base. Everything else (the EH/LSDA work, try_stmt.rs, eh_lsda.rs, the winui reorder_child, the two test-keying fixes, and the four node_compat_matrix.mjs fixes) applies to main directly.

I have an extraction in progress onto current main, preserving your authorship. It will carry the main-applicable subset only, with a rewritten changelog fragment scoped to what actually ships. This PR stays open as the record of the full Windows work order and the turnloop half.

Two things I want to flag rather than decide silently:

try_stmt.rs and eh.rs are not Windows-only files. Making Windows use "the same landingpad shape as ELF/Mach-O with Perry's own personality" is a change in shared exception-handling lowering that sits under rewrite-statepoints-for-gc. If any hunk there is unconditional rather than cfg(windows)-gated, it can move macOS and Linux behaviour, and a mistake in that area is a rooting bug — it surfaces cycles later as TypeError: value is not a function, not as a failing EH test. The extraction is classifying every hunk in those files as gated or unconditional, and I will not land an unconditional EH change without host-side evidence.

Nothing in the Windows-specific evidence can be re-checked here. The SEH personality decoding real pads, .pgcmap present under RS4GC and absent without it, 790/835 fixtures, the 10,000-connection IOCP result — all of that needs your box. The extraction's claim will be deliberately narrower: applies to main, builds on the host, does not change host behaviour. If you can re-run the Windows suite against the extracted branch once it exists, that would close the gap that nothing on this side can.

Your §3 finding is recorded as written, including the decision not to lower max_operations — "a smaller operation ceiling is a connection ceiling under another name" is the right call and worth keeping in net_config() where you put it.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Extraction doneextract/10403-windows-main-applicable @ a0aa2862bc, one commit on main (v0.5.1611), your authorship preserved. It will land via a merge train. This PR stays open for the turnloop half.

Thirteen files carried. The turnloop boundary you drew held exactly: turnloop_net/{abi,errors}.rs and event_pump/agent_loop.rs do not exist on main, and child_process/reactor.rs's hunk does not even apply — neither CpPipe nor cp_pipe_from_file exists there, and your anchor cp_register_windows_live_child is cp_register_live_child_parts on main. So defect #4 lands as 1 of its 3 sites; the other two are turnloop's.

main has moved past you on three defects — carrying them would have broken the build

main picked up 7b90108d17 ("unbreak the MSVC compiler link and the WinUI widget backend", 2026-09-16) and df29edbe46 in the 375 commits since your base:

  • reorder_child — applying yours is a duplicate definition (E0428). main already has it, and main's version adds a parent <= 0 guard yours lacks. Dropped.
  • The whole lru-subclass treatment (the perry-runtime Cargo feature, the stdlib edge, the cfg, and #[cfg(windows)] mod lru_link_shim in perry/src/main.rs) — main fixes the seven LNK2019s with MSVC /ALTERNATENAME weak defaults inside lru_subclass.rs, and 7b90108d17's own message rejects the Cargo-feature approach: "A Cargo feature cannot express 'this link has no provider'." Your comment concedes the same point and then adds real js_lru_cache_* definitions in perry.exe, which would shadow those alternates and become LNK2005 if that binary ever did link a provider. Dropped as two mechanisms for one defect. (Separately: the comment says perry-ext-lru-cache enables the feature — it does not; that crate takes perry-runtime with ["default","stdlib"] only. And as of v0.5.1612 the lru-cache binding is removed entirely.)
  • gc/tests/heap_generation.rsmain already carries #[cfg_attr(not(debug_assertions), ignore)] plus an in-code note saying "Do NOT 'fix' the test instead." Your rewrite is also inert: the ignore is still there, so the new arm can never run.

The EH change is unconditional, and I verified why that is still safe

I checked this rather than taking it on trust, because it is the one thing that could move macOS and Linux. The deleted branch in try_stmt.rs was never cfg(windows) — it keyed on ctx.target_triple.contains("-windows-"), a runtime string test that is false for aarch64-apple-darwin and x86_64-unknown-linux-gnu. The surviving code is the old else arm de-indented: same perry_eh_personality, one eh.lpad, same landingpad { ptr, i32 } catch ptr null. Off Windows it is a no-op refactor, provable from source rather than measured. Same for needs_eh_funclets in codegen/mod.rs. eh.rs's decoder relocation was verified byte-identical.

Host evidence: perry-runtime 4115/0/6 and perry-codegen 1648/0/1, both an exact tie with main; and a compiled fixture with nested try/rethrow, both finally orderings, a finally on early return, a throw across a function boundary, and 2000 allocating iterations inside a handler — byte-identical to Node 26.5.1.

Two things worth your attention

Your branch does not compile off Windows. eh.rs is #[cfg(not(windows))], so nothing in your validation ever built it. As extracted it produced E0603 (eh_walker.rs:948 calls crate::eh::find_landing_pad_in_lsda, which the move made private — that caller does not exist on your base) and E0425/E0433 ×6 from the LSDA unit tests losing DwarfReader and the DW_EH_PE_* constants. Both fixed here; the tests moved into eh_lsda.rs and now run on every host, five passing including action_zero_pad_is_still_a_handler. DispatcherContext also had to become pub — it appears in pub fn perry_eh_personality's signature and private_interfaces is a hard error under -D warnings.

ARM64 Windows is unverified, and its guard cannot fail. DispatcherContext is documented as the x64 DISPATCHER_CONTEXT, carries offset_of! asserts at 0x38/0x40, and is not cfg(target_arch)-guarded — while windows-arm64-build is a live CI job. Both targets are LP64, so those asserts pass on ARM64 whether or not the layout is right. The fields the handler reads do appear to sit at matching offsets in the documented ARM64 layout, so it is probably fine — but "probably fine, and the assert can't discriminate" is precisely the shape CLAUDE.md's ★ section is about. Recorded as an open risk in the fragment.

What none of this proves is the thing your PR exists to do. It shows Windows EH compiles and that nothing else regressed. That Windows actually gets precise roots — your .pgcmap present-vs-absent A/B — still needs your box. Please don't read a green build here as confirmation of that. If you can re-run the Windows suite against extract/10403-windows-main-applicable, that closes the one gap nothing on this side can.

proggeramlug pushed a commit that referenced this pull request Sep 20, 2026
Main-applicable extraction of PR #10403, which targets the draft
turnloop/integration branch (375 commits off main) and cannot land as it
stands. The turnloop-only half stays in #10403.

windows-msvc lowered try/catch to SEH funclets, and LLVM's
rewrite-statepoints-for-gc does not support funclet EH, so a Windows module
containing a try could have precise moving-GC roots or compile, never both.
It now emits the same single landingpad shape as ELF/Mach-O under Perry's own
personality; LLVM then classifies the function as non-funclet EH and emits an
Itanium GCC_except_table on COFF, which a new x64 language handler in
eh_windows.rs walks and transfers through RtlUnwindEx. The LSDA decoder moves
out of eh.rs into eh_lsda.rs verbatim so both personalities share it.

For every non-Windows target triple the emitted IR is unchanged: the branch
removed from emit_eh_dispatch and from declare_phase_b_strings_part2 already
selected the landingpad arm there, and needs_eh_funclets()/
declare_seh_machinery() had no callers outside the Windows path.

Also: perry-ext-http reports libuv's err.errno on Windows rather than a
negated Winsock number (non-Windows arm byte-identical); the malloc-trim
telemetry test asks the mimalloc purge witness instead of an OS list; and
node_compat_matrix.mjs honours PERRY_BIN, finds a produced .exe, and raises
the cold-compile timeout that was fabricating prefix divergences.

Not carried, because main already fixes them in 7b90108 and with an
existing cfg_attr(ignore): the lru-subclass feature plus perry.exe link shim,
perry-ui-windows-winui's reorder_child, and the heap_generation funnel-assert
rewrite.

Validated on macOS/aarch64 only. No Windows behavioural claim from #10403 is
re-verified here.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

The extraction landed — merge train 236 (#10790) as v0.5.1615, cdd65e61b1. Your authorship is preserved on the commit. This PR stays open for the turnloop half.

What went in: the EH/LSDA work (try_stmt.rs, eh.rs, the new eh_lsda.rs, eh_windows.rs, codegen/mod.rs, module.rs, strings_part2.rs), the libuv errno translation in transport_error.rs, the telemetry_verifier mimalloc-witness fix, and the four node_compat_matrix.mjs fixes. 13 files, +810/−473.

Validation was eight gap areas chosen for the exception-handling blast radius, since Windows itself cannot be exercised here — gc_ 54, async 19 (all async lowers to a try), throw 10, error 10, promise 10, try_ 7, catch 3, finally 3. Zero unexplained regressions; six unit suites with an empty failing set; all five pinned artifacts byte-identical before and after.

Two things I'd like your box to close, which nothing on this side can.

First: this train proves Windows EH compiles and that nothing else regressed. It does not prove the thing your PR exists to do — the .pgcmap present-under-RS4GC / absent-without A/B, the PERRY_EH_TRACE personality decode, and the 790/835 fixture run all need real Windows. If you can re-run your suite against main at v0.5.1615, that closes the one gap here.

Second, and more pressing: ARM64 Windows is unverified and its guard cannot fail. DispatcherContext is documented as the x64 DISPATCHER_CONTEXT, carries offset_of! asserts at 0x38/0x40, and is not cfg(target_arch)-guarded — while windows-arm64-build is a live CI job. Both targets are LP64, so those asserts pass whether or not the ARM64 layout is correct; they cannot discriminate. The fields the handler reads do appear to sit at matching offsets in the documented ARM64 layout, so it is probably fine — but an assert that passes either way is not evidence. It is recorded as an open risk in the changelog fragment rather than quietly assumed.

A correction worth having for the turnloop half. Your branch does not compile off Windows — eh.rs is #[cfg(not(windows))], so your validation never built it. As extracted it produced E0603 (eh_walker.rs:948 calls crate::eh::find_landing_pad_in_lsda, which your move made private; that caller does not exist on your base) and six E0425/E0433 from the LSDA unit tests losing DwarfReader and the DW_EH_PE_* constants. Both fixed here, with the tests relocated into eh_lsda.rs so they now run on every host — five pass, including action_zero_pad_is_still_a_handler. DispatcherContext also had to become pub for -D warnings. Worth folding back into codex/win-10385 so the turnloop half does not hit the same wall.

Three of your ten defects were dropped as superseded: reorder_child (a duplicate definition, E0428 — main's version from 7b90108d17 also has a parent <= 0 guard yours lacks), the whole lru-subclass Cargo-feature treatment (7b90108d17 rejects that approach explicitly, and v0.5.1612 removed the lru-cache binding outright), and the heap_generation.rs rewrite (inert behind an ignore that main already carries, with an in-code note not to "fix" the test). Defect #4 therefore lands as 1 of its 3 sites; the other two are in turnloop files.

Your §3 finding is untouched and still stands, including the decision not to lower max_operations — "a smaller operation ceiling is a connection ceiling under another name" is the right call.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Status from the merge queue — this needs a rebase, and the useful thing is when, not just that.

Where it actually stands

mergeable = CONFLICTING, and a conflicting PR gets zero CI runs, so the single green CodeRabbit check is the only signal on it. That is not "waiting for review"; nothing has been built.

Measured against origin/main at v0.5.1623:

merge base           fcd108bfb0   (5 days old)
commits behind main  443
files in the PR      333
  ... of which main has also changed since the base:  61

Note for anyone reading the PR page: GitHub reports 25 changed files, and the real diff is 333. For a conflicting PR it cannot compute a merge, so that count is not trustworthy here — take the number from git diff $(git merge-base origin/main <head>)..<head>.

Why now is the wrong moment to rebase

Four of the 61 collision files are being changed by trains already in flight:

file in flight as
crates/perry-runtime/src/gc/mod.rs train 246 (#10834 + the PASS1_MARKED pin re-audit)
scripts/gc_runtime_root_holders.json train 246 (same pin)
crates/perry-runtime/src/timer.rs train 247 (#10836 — a ~345-line split to clear the 2000-line cap)
crates/perry-runtime/src/object/mod.rs train 247 (#10836 — 2007 lines, needs 7 off)

Rebasing today means resolving those conflicts, then resolving them again in a day when 246 and 247 land. timer.rs in particular is being physically split, so a rebase done first would be reconciling against a file that is about to stop existing in its current shape.

Sequencing

  1. Land 246, then 247.
  2. Rebase this branch once, onto the settled main.
  3. Then it gets CI for the first time — which is the actual unknown here, since nothing in the 333 files has been built against a current tree.

Also worth flagging for the rebase: the PR touches scripts/unrooted_local_shape_baseline.json, scripts/raw_handle_debt_files.txt, workspace-architecture.json and test-parity/known_failures.json. Those are absolute ratchets — their values are chained, so a baseline re-derived against a 5-day-old main will be wrong against the current one and must be re-derived after the rebase, not carried through it.

Leaving this open and queued; I will take the rebase after 247 rather than asking anyone to redo it twice.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audited this while assembling merge train 254 (every open, undrafted PR). It is not a train candidate, and it should not be rebased onto main. Recording the accounting so the next person does not repeat the work.

It is a stacked PR, and measuring it against main measures the wrong thing

Its base is turnloop/integration (PR #10354, open/draft/conflicting), not main. Against main the branch reads as 191 commits / 333 files; against its own base the PR is 5 commits, 25 files, +1281 −466. A commit-subject census of those 191 says the same thing: 133 are turnloop/docs(turnloop), 14 are merges, and only 5 are #10385. Of 60,931 added lines, 26 mention cfg(windows); 3 of 333 paths contain "win".

So git rebase origin/main here would retarget a stacked PR onto the wrong base, inflate it to ~191 commits, and absorb someone else's parked PR. A --force-with-lease push would then destroy the stack.

Two further reasons not to: the #10354main merge was deliberately parked until the group-A lane lands, specifically so timer.rs is not resolved under a moving branch — and this branch still carries the bug #10447 fixed (the bounded timer id→ref-state registry evicting still-scheduled ids), where a plausible text merge compiles and quietly reinstates it.

The landable half already landed

eee081c04c on main (2026-09-20), "fix(windows): put windows-msvc on the Itanium landingpad shape (#10385)" — 14 files, +810 −473. Its fragment changelog.d/10385-windows-support.md says so in its own words, and carries a "Deliberately not carried from #10403" section.

Full disposition of all 25 files, each read rather than inferred:

Disposition Files
Already on main via eee081c04c 14 try_stmt.rs, module.rs, transport_error.rs, node_compat_matrix.mjs, telemetry_verifier.rs are byte-identical; eh.rs/eh_windows.rs/eh_lsda.rs landed too
main has a strict superset 1 eh_lsda.rs: main 304 lines vs 196, identical first 194 plus tests
main has the better version 1 winui reorder_child — main's has a parent <= 0 guard this one lacks; without it handle 0 → node 0 via saturating_sub(1) reorders the wrong subtree
main solved it differently, and rebuts this approach 1 gc/tests/heap_generation.rs: this asserts caught.is_ok() in release (a vacuous arm); main uses #[cfg_attr(not(debug_assertions), ignore)] with an in-code note not to "fix" the test
Moot — main removed the subject 3 the lru-subclass feature + 61-line perry.exe link shim. 7b90108d17 fixed the MSVC link with /ALTERNATENAME weak defaults and explicitly rejects the Cargo-feature approach; the LRUCache binding has since been removed entirely, so lru_subclass.rs no longer exists
Must not be touched by a contributor PR 3 CLAUDE.md, Cargo.toml, Cargo.lock version churn
Genuinely unlanded, turnloop-only 4 below

The top two commits are a clean revert pair (git diff 255e5d98e6~1 b3adf5f172 is empty), so the net content is 3 commits.

What is actually left: 4 files, +179 −8

turnloop_net/errors.rs (+149), event_pump/agent_loop.rs (+26), turnloop_net/abi.rs (+8), child_process/reactor.rs (+4, needs cp_pipe_from_file from the branch-only reactor/streams.rs). Three of those paths do not exist on main at all.

It is a real fix and worth keeping: on Windows libuv's errno space is unrelated to Winsock (UV_EADDRINUSE is −4091, not −10048), so negating the OS code produced a value no Node program matches. main already has the equivalent fix for the non-turnloop copy (perry-ext-http/src/transport_error.rs); this is the same fix for the turnloop path, and it can only land when turnloop does.

Suggested disposition

Reduce this PR to that 4-file delta, keep it based on turnloop/integration, and retitle it — the current title promises end-to-end Windows support that has already landed elsewhere. Better still, fold those four files into #10354 and close this.

Either way the real blocker is #10354, and it is not a conflict-resolution problem: main has deleted 19 npm-binding crates since the merge base (the strip-native-bindings decision), the branch migrates three of them (axios, mysql2, pg) to turnloop, and 14 files / +5,348 −107 of its work is dead on arrival. That needs a scope decision before any merge work.

No Windows host was available here, so nothing Windows-side was re-verified — same caveat main's own extraction fragment records.

Base automatically changed from turnloop/integration to main September 22, 2026 23:48

This branch has not been deployed

No deployments
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.

1 participant