Windows: make the platform buildable and Node-correct end-to-end (#10385) - #10403
proggeramlug wants to merge 191 commits into
Conversation
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.
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.
…nloop/integration
`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.
|
Important Review skippedToo 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (332)
You can disable this status message by setting the 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. Comment |
`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.
|
Audit note from the merge queue — this PR cannot land on Its base is That would be a waste, because by your own accounting nine of the ten defects are pre-existing on I have an extraction in progress onto current Two things I want to flag rather than decide silently:
Nothing in the Windows-specific evidence can be re-checked here. The SEH personality decoding real pads, Your §3 finding is recorded as written, including the decision not to lower |
|
Extraction done — Thirteen files carried. The turnloop boundary you drew held exactly:
|
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.
|
The extraction landed — merge train 236 (#10790) as v0.5.1615, What went in: the EH/LSDA work ( Validation was eight gap areas chosen for the exception-handling blast radius, since Windows itself cannot be exercised here — 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 Second, and more pressing: ARM64 Windows is unverified and its guard cannot fail. A correction worth having for the turnloop half. Your branch does not compile off Windows — Three of your ten defects were dropped as superseded: Your §3 finding is untouched and still stands, including the decision not to lower |
|
Status from the merge queue — this needs a rebase, and the useful thing is when, not just that. Where it actually stands
Measured against 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 Why now is the wrong moment to rebaseFour of the 61 collision files are being changed by trains already in flight:
Rebasing today means resolving those conflicts, then resolving them again in a day when 246 and 247 land. Sequencing
Also worth flagging for the rebase: the PR touches Leaving this open and queued; I will take the rebase after 247 rather than asking anyone to redo it twice. |
|
Audited this while assembling merge train 254 (every open, undrafted PR). It is not a train candidate, and it should not be rebased onto It is a stacked PR, and measuring it against
|
| 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.
Runs the #10385 Windows work order against
turnloop/integration, on a realWindows 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-runtimegreen at 3942/0.Ten defects, one of them turnloop's and nine pre-existing on
main:cfg(windows)fork path never migrated to P2'sCpPipelru_subclassunlinkable (#10293)err.errnonegated-OS rather than libuv's, ×3 sitesperry-ui-windows-winuimissingreorder_childwindows-buildred, and masked 2 and 3perry-devprofilePERRY_BINwithout.exe.exeThe through-line is that each break hid the next.
windows-builddies on a UIcrate 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/landingpadbecause longjmp could skip a statepoint relocation write-back (#7174); on
windows-msvc that lowering becomes SEH funclets, and
rewrite-statepoints-for-gcdies on funclets (reproduced on LLVM 22.1.8, notjust the 22.1.3 the refusal cites). So Windows could have precise roots or
try, never both — and since essentially all async lowers to atry, the wholeturnloop 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_personalityplus an ItaniumGCC_except_tableon COFF;a new x64 language handler walks that LSDA and transfers via
RtlUnwindEx.Verified live —
PERRY_EH_TRACEshows the personality decoding a real pad percatch, output is byte-identical to Node across nested rethrow and
finally, and.pgcmapis present (1216 B) under RS4GC and absent without it, so thestatepoints 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_768vs 32.9 MB at 2_048 (10.3 MBwith no net loop), i.e. ~36 MB is the ceiling alone — matching
(32768-2048) x 1096 Bfor the eager, non-paged IOCPKernelslab. 8,700bytes/connection against Linux's 6,228.
max_operationsis deliberately not lowered. A connection costs two handlesand 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
trees'
target/(a full ext-routed gap run transiently wants ~20 GB ofauto-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_socketsis diagnosed, not fixed: a failed AF_UNIXlisten()reportsEINVALwhere Node reportsEACCES, andsocket_events.rsprints a diagnostic where Node throws the unhandled
'error'. The latter is adeliberate, documented cross-platform deviation, so changing it from a
Windows ticket seemed wrong.
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_fingerprintbut validated at link againstperry-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.