turnloop: replace tokio as Perry's event loop (P0–P8) - #10354
Draft
proggeramlug wants to merge 236 commits into
Draft
proggeramlug wants to merge 236 commits into
proggeramlug wants to merge 236 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.
…op/integration # Conflicts: # crates/perry-ext-http/src/server/turnloop_h2/mod.rs
…op/integration # Conflicts: # scripts/tokio_inventory.json
…mit interruption Committed by the integrator so nothing is lost. Not reviewed, not validated, and not necessarily coherent -- the lane was mid-flight.
…mit interruption Committed by the integrator so nothing is lost. Not reviewed, not validated, and not necessarily coherent -- the lane was mid-flight.
…oop/integration # Conflicts: # crates/perry-ext-fastify/src/server.rs # scripts/tokio_inventory.json
…eline The TLS lane added `perry-tls-turnloop` without classifying it, so `workspace_architecture.py --check` failed on an unclassified crate. It is `runtime-core` / `keep`, the same as every one of its siblings (perry-tls-session, perry-db-turnloop, perry-http-client, perry-http-server). The baseline is derived data, so it is recomputed with the gate's own `load_metadata`/`workspace_packages`/`dependency_closure` rather than typed from the error message: members 86 -> 87 and `keep` 49 -> 50, with both dependency closures unchanged at 21 and 20. One crate added, nothing else moved.
…oop/integration # Conflicts: # scripts/tokio_inventory.json
…named-error ctor The lane was interrupted mid-edit by a rate limit, leaving three things unfinished: - `fetch/mod.rs` reached 2040 lines, 40 over the cap. `HeadersStore`, its impl and `headers_from_header_map` move to `fetch/headers_store.rs` (78 lines), taking their `#[derive]` with them and widening the field and methods to `pub(super)` — siblings reached them when they shared a module and could not once they did not. mod.rs is 1963. - `perry_runtime::error::js_error_new_with_name_message` was `pub(crate)`, so the fetch transport could not build a `cause` carrying the `.name` Node sets (`ConnectionRefused`, `AbortError`). Its sibling `js_error_new_with_message` has been `pub extern "C"` all along; only the named form was unreachable. - `nodemailer.rs`'s unrooted-local baseline entry went stale when the lane emptied the file. A stale entry fails by design; deleting it is the fix, and the total tightens 539 -> 537. perry-stdlib compiles clean. The lane's own work (lettre's async transport out, fetch and nodemailer on turnloop) is unchanged.
added 18 commits
September 16, 2026 23:43
…repair the A/B arm perry#10395 step 1. A `Route` now carries the loop's `Poster` beside its `Notifier`, and `event_pump::post_to_agent` hands work to an agent's owner from any thread — the enabler the 19 decline-path tokio edges are waiting on. It is sound because both threads serve the same agent's heap, so nothing crosses an agent boundary. `PostToAgentError` keeps "no loop exists" separate from "that loop refused this", and carries `Poster::post`'s retry contract through intact: a refused payload comes back, a wake error after enqueue does not (the post was accepted; retrying would deliver twice). The poster is cloned out from under the `ROUTES` lock so a cross-thread wake never happens inside it. Two tests assert the mechanism: a foreign thread's payload arrives on the owner exactly once with token and value intact, an agent with no route gets a named error rather than silence, and a full postbox hands the payload back. Separately, and found by checking this against the baseline: the `tokio-wait-driver` arm has not compiled since the P3 timers commit, which left `arm_agent_timer`'s import without the cfg every other item in that block carries. That arm is the baseline of the tokio-vs-turnloop measurement, and no required gate builds it. Restored with the missing no-op mirror.
The measurement has two arms and only one of them was built by any job, which is how the baseline stopped compiling in the P3 timers commit without a single red run. `check` rather than `warnings`: that arm legitimately leaves the turnloop-side code dead, so `-D warnings` there would be noise, while a build failure is exactly the signal that was missing.
…erved The harness verified the arm MARKER, which proves the wait driver and nothing about the transport. `try_listen_on_turnloop` declines whenever the thread cannot get a loop of its own — the P1 coexistence rule, and the reason group A's edges survive — and a declined server still parks in a turnloop loop, so it still prints `driver=turnloop`. Every number from such a run would describe a hyper server labelled turnloop. `LoopStats::completions` is the discriminating quantity, already printed in the marker line and already documented as "Zero means turnloop carried no I/O for this process, whatever the turn count says". The harness never read it. Now checked at build verification — which already serves one real request, so a decline fails in seconds rather than after hours of measurement — and again per sample. Necessary, not sufficient, and the comment says which: a non-zero count proves turnloop carried some P1 net I/O, not specifically this listener. Self-tested against a synthetic completions=0 marker: a gate nobody has watched fail is not a gate.
perry#10395 step 2. `event_pump::post_to_agent` let any thread hand work to the loop of an agent another thread owns, but no binding crate could reach it: the database extensions depend on perry-ffi only. This is the C seam. Three exports in the new `turnloop_post` module, and one rule — the sign of the return code is the ownership of the caller's context. `>= 0`: the runtime took it and will invoke the callback exactly once. `< 0`: untouched, still yours, use your fallback. A wake failure after enqueue lands on the consumed side, which is how `Poster::post`'s no-double-delivery contract survives the crossing. The ABI takes no agent argument on purpose. Posting is sound because both threads serve the same agent's heap, and an ABI that let a binding name an arbitrary agent would make the unsound call expressible; the runtime resolves `current_agent()` itself instead. Posted jobs get token class 0x30..=0x3F and an explicit branch in `dispatch_staged`, because P1 is that router's fall-through: an unbranched class would be delivered to the net sink rather than rejected. `perry_ffi::agent_post::post_job` is the safe face — it takes a Box and returns it only when the post did not land, keeping 'no loop, use your fallback' apart from 'retry'. The end-to-end test asserts the callback ran on the OWNER and not the poster, once, and watches the runtime's dispatched counter move, so a green run cannot mean nothing listened. Separately: an isolated `cargo check -p perry-ffi --all-targets` was already red with nine dead-code errors in turnloop_net, hidden from workspace builds by the runtime-link feature unification a binding's dev-dependency causes. Gated.
Group H of the P8 removal plan, minus its fourth edge. Takes perry-stdlib's `sqlx`, `redis` and `mongodb` manifest edges out; the tokio inventory goes 29 -> 26. Since v0.5.565-568 the well-known flip has stripped `bundled-pg` / `bundled-mysql2` / `bundled-ioredis` / `bundled-mongodb` and routed every `pg` / `mysql2` / `mysql2/promise` / `ioredis` / `redis` / `iovalkey` / `mongodb` import to perry-ext-pg / -mysql2 / -ioredis / -mongodb. The bundled copies survived only as the fallback the flip declined to. Every `extern "C"` symbol they defined is also defined by the matching wrapper, and each wrapper defines strictly more (pg 10->25, mysql2 15->30, ioredis 18->33, mongodb 26->41), so no JS surface was served only by the copy. The two fallback paths now fail loudly, matching the precedent set when the in-stdlib fastify adapter was removed: `PERRY_DISABLE_WELL_KNOWN=1` plus a db import, and a workspace missing `crates/perry-ext-<driver>/`, are compile-time errors naming the variable / the crate and path. Ordinary builds are unaffected; `PERRY_NO_AUTO_OPTIMIZE` still links the prebuilt `libperry_ext_*.a` ahead of the stdlib archive. `module_to_features` now maps those seven spellings to no feature at all (the `undici` / `node:http` shape) and `optimized_libs/driver.rs` re-asserts `async-runtime` for them by module name -- the wrappers still settle promises through perry-stdlib's `perry_ffi_*` shim, and anything named in `module_to_features` gets stripped by the flip loop. `database` is now `["database-sqlite"]`: rusqlite is not a tokio driver and `dispatch_sqlite_stmt` is deliberately retained through the flip (Refs #643). perry-stdlib's own normal dependency closure drops 502 -> 426 packages (-76, -15%). Cargo.lock does not shrink: the ext wrappers keep the same drivers in the graph, so P8's "cheapest lockfile reduction in the tree" does not hold while they exist. `perry-stdlib -> tokio-rustls` is deliberately NOT removed. It is not a bundled fallback -- it is the live `node:tls` implementation, the bundled net client's TLS and `bundled-ws`'s `wss://` connector, and the flip turns it ON (`external-tls-server` for every node:http/https/http2 import, `external-net-tls` whenever net/http own the transport). Its inventory entry is rewritten to say that instead of inheriting the deleted copies' blurb. Also adds the `#[cfg(test)]` that `intern_syscall_for_test` was missing next to its sibling test seams, which had `cargo check --all-targets` under `RUSTFLAGS=-D warnings` failing on dead code.
… copies (group H) 29 -> 26 tokio edges. sqlx, redis and mongodb leave perry-stdlib; tokio-rustls STAYS, and that is the lane's finding rather than a shortfall: it is node:tls including the TLS server, no perry-ext-* crate owns a TLS server, and the well-known flip TURNS IT ON (external-tls-server) rather than compiling it out. Its inventory entry had inherited the deleted copies' annotation and now states its real blocker. The brief's premise was also wrong and the lane corrected it: these copies were NOT compiled out of every default build in the cargo sense — default = full implies database — only in the auto-optimize sense, via the well-known flip. So every shipped libperry_stdlib.a did contain them. Both fallback paths that used to reach the bundled copy now fail with a named error instead of a wall of undefined symbols, and module_to_features keys async-runtime on the module name, because with the features deleted it could no longer see these modules and the wrappers still settle promises through perry-stdlib's perry_ffi_* shim.
perry#10395 step 2, first converted binding. `perry-ext-ioredis` had two transports; it now has three, and the middle one is what the tokio dependency was being kept for. Direct — this thread owns the loop: unchanged. Posted — ANOTHER thread of this same agent owns it: hand the command over. Legacy — no loop exists for this agent at all: the redis crate. Since P9 gave every JS agent a loop, 'a thread that could not get a loop of its own' stopped meaning 'a worker' and started meaning 'a second thread acting for an agent another thread already owns' — the Android shape, where perry-native runs the compiled TypeScript while the UI thread pumps for the same heap. Every js_ioredis_* entry point now routes through the owner on that path: sixteen commands, plus connect, quit and disconnect. Two things come free. The reply is built on the OWNER, which is where that agent's values live — the #1824 rule the spawn_blocking path obeyed by hand. And a posted command holds OWNED bytes, because the borrowed slices a direct submission passes cannot cross a thread; they are copied once, at the post. Settlement survives every refusal: a dropped JsPromise never settles, so a post that does not land hands the job back and the promise is rejected here — NoRoute as a closed connection, Again as back-pressure. No falling back to the redis crate at command time: a turnloop client may have commands queued and reordering them would break a MULTI block. Found while testing this: net_available() CLAIMS a route without building a loop, and the Poster is published only when the loop is built — so a claim is nothing to post to. agent_post::available() answers the published question, and a client created inside that window is legacy for its life. Narrow, and documented rather than papered over. The test asserts the work CROSSED: the dispatch counter is per-thread, so it moving on the owner while staying at zero on the poster is the discriminating fact. A post that went nowhere leaves both at zero and fails. THE EDGE DOES NOT MOVE, and that is the honest answer — the inventory still reads 29. Two decline reasons survive, neither specific to redis and neither a hole in this binding: the tokio-wait-driver A/B arm compiles no agent loop at all (it exists to measure the transport this replaces, so it must decline), and a host where Loop::new failed has nothing to post to. Both are one shared decision, not four conversions. The inventory entries say so.
turnloop's Loop has no Drop that drains its postbox, so a job still queued when the owner's loop goes down is dropped without being invoked. The context leaks rather than being freed twice — the safe direction — but a caller whose job was going to settle a JsPromise gets neither a completion nor an error. Bounded to agent teardown (a retiring Worker, or process exit), when that agent's heap is going away anyway. Documented on both faces rather than left implicit: 'accepted' otherwise reads as 'will run' without qualification, and a binding that must settle something can do it in its job's Drop.
…n it (group B) perry#10395 step 2. Three #[no_mangle] exports in turnloop_post give a separately linked binding — which can only depend on perry-ffi, so it can name no turnloop::Payload and hold no Rust closure — a way to run a job on its agent's owner: a function pointer plus an opaque context, boxed for the owner. One rule governs the ABI: the SIGN of the return code is the ownership of the context. >= 0 the runtime took it and will invoke the callback exactly once; < 0 it is untouched and still the caller's. That preserves Poster::post's no-double-delivery contract across the C boundary — a wake failure AFTER enqueue reports 1, on the consumed side, because retrying would deliver twice. There is deliberately no agent argument. post_to_agent is sound precisely because both threads serve the same heap, so the runtime resolves current_agent() itself and the unsound call is inexpressible rather than merely discouraged. Posted jobs get their own token class 0x30..=0x3F with an explicit branch, because P1 is dispatch_staged's FALL-THROUGH: an unbranched class would not fail loudly, it would hand a boxed job to the net sink. perry-ext-ioredis gains a third transport, Posted, for all 16 commands plus connect/quit/disconnect. The #1824 hazard goes with it structurally: a posted command is answered on the agent's owner, which is where its values live. The edge count does NOT move, and the lane said so plainly. Two reasons survive, and they are the same pair every plan-A and plan-B edge names: the tokio-wait-driver A/B arm compiles no agent loop at all, and a host where Loop::new failed has nothing to post to. That is one shared decision, not four more conversions.
29 manifest edges -> 25. Both groups removed whole:
E perry-ext-ws -> tokio
F perry-ext-fastify -> hyper, hyper-util, tokio
The inventory recorded F as blocked on "an owned `AsyncRead + AsyncWrite`
stream a turnloop connection cannot produce", with two ways out: a descriptor
handoff, or perry-ext-ws on turnloop-websocket. Neither was needed --
perry-ext-ws was already on turnloop-websocket, and only its transport was
still on tokio. Closing E closed F.
perry-ext-ws: io.rs (the tokio stream driver) deleted. New turnloop_io.rs
carries the outbound client on tcp_connect with perry_tls_session above the
same handle for wss://; the standalone WebSocketServer({port}) binds through
perry-http-server and takes the upgrade through its Host hook. The generic
`register_upgraded_stream<S: AsyncRead + AsyncWrite>` becomes
`adopt_host_connection(conn_id, Transport, leftover)`, so perry-ext-http's
hyper upgrade path keeps its own stream and drives it there. turnloop_link's
Transport is now per link, since three hosts exist in one binary.
perry-http-server: grew the upgrade hook its own header recorded as withheld
"until that is solved, with a caller" -- takes_upgrades / on_upgrade /
on_upgraded, plus a public finish() for the graceful close an upgraded
protocol needs. Two callers, not a speculative seam.
perry-ext-fastify: FastifyHost implements it; the listen-time decline, the
hyper accept loop, its service fn, its upgrade task, Reply::Hyper and
cluster_bind's second binder are deleted.
perry-runtime: MAX_SUBSYSTEMS 8 -> 16, so slot 8 can register at all, and the
comment now records the three slot collisions the P5 and P7 ledgers left
behind.
Narrowing, deliberately: an agent with no turnloop::Loop now has no WebSocket
client, no standalone WebSocket server and no fastify server, and each says so
instead of silently taking a tokio path that no longer exists.
…o (groups E and F) Both groups removed WHOLE — 4 edges. perry-ext-ws -> tokio, and perry-ext-fastify -> hyper, hyper-util, tokio; fastify also drops http-body-util, bytes and socket2. Neither crate appears in the inventory now. The recorded blocker was stale, and that is the lane's most useful finding. The inventory said F needed an owned AsyncRead + AsyncWrite stream a turnloop connection cannot produce, and named two exits: a descriptor handoff via Driver::detach, or perry-ext-ws on turnloop-websocket. NEITHER was needed — perry-ext-ws was already on turnloop-websocket, the protocol having moved earlier and left only the transport on tokio. So E's fix WAS F's fix, and no new FFI primitive was required. perry-http-server gains the upgrade hook its own header recorded as withheld 'until that is solved, with a caller'; it landed with two callers. MAX_SUBSYSTEMS goes 8 -> 16, because slot 8 would otherwise have been refused and left available() quietly false. # Conflicts: # scripts/tokio_inventory.json
… loop The WS/fastify lane's changelog listed a `worker_threads` agent among those that own no `turnloop::Loop` and therefore lose WebSockets entirely. That has not been true since P9: `a_worker_agent_gets_its_own_loop` asserts a worker agent's `net_available()` is true and its `ensure_loop()` succeeds. The code was never wrong — it gates on `perry_ffi::turnloop_net::available()`, which answers true for a worker — so the narrowing is strictly smaller than advertised: the `tokio-wait-driver` arm and a failed `Loop::new`, which is the same pair every remaining plan-A and plan-B edge names.
…hree slots register_sink stored the pointer and returned true unconditionally, so two bindings numbered onto one slot each believed they were registered while every completion for it went to whichever registered last — which reads the token's low bits as one of its OWN connection ids. Three pairs shipped that way, because the P7 database lane and the P5 server lane numbered from two different ledgers: perry-ext-pg with perry-stdlib's turnloop HTTP client on 2, perry-ext-mysql2 with perry-ext-fastify on 4, and perry-ext-ioredis with perry-stdlib's framework server on 5. Reaching one needs a program linking both bindings — a fastify app using mysql2 — which is why nothing caught it, and this PR just made it more reachable by moving fastify onto turnloop. register_sink now refuses a slot held by a DIFFERENT sink, so the loser keeps its fallback instead of corrupting the winner's table; re-registering the same sink stays idempotent. The database band moves to 9..=12. And scripts/subsystem_slots.py resolves every slot across every crate and fails on a collision, so the two ledgers cannot drift apart again. Both guards sabotage-checked: deleting the register_sink check fails the unit test on its own message, and restoring 2/4/5 makes the script name all three pairs.
A reused keep-alive connection paid 4 completions per request: the request's read, the response's write, and two more from the idle keep-alive deadline being destroyed and rebuilt every time. `on_data` disarmed the deadline with `timer_cancel`, which closes the handle, and turnloop answers a close with a `Cancelled` for the pending timer operation and a `Closed` for the handle. `dispatch` drops both, since an unfired deadline has nothing to deliver, and `complete_response` then had to build a fresh handle because the record `timer_arm` looks for was gone. `timer_park` disarms by moving the deadline out of reach instead, so the disarm and the re-arm are both a `timer_reset` — no completion, and one handle per connection rather than one per request. `timer_cancel` keeps its destroying meaning for teardown; a timer that already fired or is closing falls back to it. Measured by slope between 1,000 and 3,000 requests on one keep-alive connection: completions/request 4.000 -> 2.000, net timer completions 2.000 -> 0.000, timer handles created 1.000 -> 0.000. CPU is unchanged within error (-1.1% median over 15 interleaved rounds at 64 connections, against a 12-28 us spread). Idle close still fires at 1501 ms with keepAliveTimeout = 500, matching Node 26.5.1 exactly. Adds `turnloop_net::census`, a `PERRY_LOOP_STATS=1` per-op-class census that made the decomposition possible; the aggregate `completions=` field mixes P1/P2/P3/P4 and cannot answer what one request cost.
The tokio inventory held `perry-ext-http -> h2` open on a missing
capability: "the TLS **client**: `perry_ext_net::turnloop_tls_io` exposes
`install_server_session` publicly but only `begin_client_upgrade`
(`pub(crate)`…), so `http2.connect('https://…')` has no way to install a
client session on a turnloop socket."
That is not true of this tree. `turnloop_tls_io::install_client_session` is
public, takes neither `TlsClientConfigData` nor a `JsNativeAsyncCompletion`,
and names `http2.connect('https://…')` in its own doc comment as the reason
it exists; `turnloop_h2::conn::connect_client` has been installing a real
client session with `h2` in ALPN since it landed. The entry, not the tree,
was out of date.
What was left behind was a fallback with no remaining reason, so it is
deleted rather than kept:
* `h2::client::handshake` on a private `current_thread` runtime per
`http2.connect`, plus a second private runtime per `session.request()`.
* `connect_h2_stream`, which returned a bare `tokio::net::TcpStream` and
ignored `secure` — `https://` there opened a CLEARTEXT socket and sent
an HTTP/2 preface at a TLS listener, so the very surface the blocker
described could not work on that path.
* `Http2SessionHandle::sender`, the `Arc<Mutex<Option<SendRequest>>>`
concurrent `session.request()` calls raced for.
An agent that cannot reach a loop now gets an `'error'` from
`http2.connect` and a stream error from `session.request()`, instead of a
transport that only ever carried cleartext. That is the rule perry-ext-ws
set: write the narrowing down, do not keep a fallback nobody exercises.
Inventory: 22 manifest edges -> 21. `perry-ext-http -> tokio` SURVIVES and
its entry now says why in its own terms rather than "the union of the rows
above" — `reqwest` + `tokio-rustls` (plan C, the node:http client) and
`hyper` + `hyper-util` (plan A, the declining server) hold it open, exactly
as the plan's row D predicted. `node:http2`'s client reaches no tokio at
all now: `http2_server/session.rs` contains no `tokio::` code.
Tests assert their subject: `turnloop_is_live_as_the_http2_client_transport`
checks `turnloop_net::sink_installed` so the decline tests cannot pass with
nothing listening — it was written expecting a cargo-test thread to own no
loop, and failed, which is why the decline tests assert their fixtures
rather than the environment. All four new gates were watched failing
against planted defects (an `http/1.1` added to the ALPN offer, a dropped
stream error, a session left `connecting`, and `install_client_session`
narrowed back to `pub(crate)`).
…t -> 2 Decomposed by slope between 1,000 and 3,000 requests on ONE keep-alive connection, so accept/connect/close cancel out. The per-request numbers came out as exact integers: 1.000 read, 1.000 write, and 2.000 TIMER completions routed nowhere. The two timer completions were the connection's idle keep-alive deadline being destroyed and rebuilt on every request. turnloop_serve's on_data disarmed it with timer_cancel, which is driver.close(handle), and turnloop answers a close with TWO completions — the pending op's Cancelled, then the handle's Closed — both of which dispatch drops, because an unfired deadline has nothing to deliver. complete_response then had to build a fresh handle, because the cancel had removed the record an in-place re-arm looks for. That is exactly the churn timer_arm's own doc says it exists to avoid. timer_park moves the deadline a year out instead of destroying it, so disarm and re-arm are both a timer_reset: no completion, and one handle per CONNECTION rather than per request. It falls back to destroying when a timer has already fired or is closing, so a live deadline is never left armed. Two of the hypotheses in the brief were ruled out with evidence: the response is already ONE write submission, and the read is multishot and never re-armed per request. CPU is unchanged within measurement error and the lane does NOT claim a win: 15 interleaved rounds at c=64 gave -1.1% against a per-round spread of 12-28 us/req on a contended host. The removed work is real but sits under this host's resolution. Idle close still fires at 1501 ms with keepAliveTimeout=500 before, after, and under Node 26.5.1. # Conflicts: # crates/perry-runtime/src/turnloop_net/tests.rs
…asses The gate asserted `completions > 0` and cited the runtime's doc for that field, which said it counted "completions dispatched to a P1 net subsystem". It does not: record() sums the driver's whole turn output across every class before dispatch routes any of it. So a server that declined its listener to hyper but armed a keep-alive deadline would have passed — and until the idle timer park landed, an HTTP keep-alive connection produced two timer completions per request, which is exactly that shape. The gate now reads turnloop_net::census's per-class `[perry-loop] p1 comp_*` line and requires a non-zero count among accept/read/write/connect. The aggregate stays only as a fallback for a binary older than the census, and the message says when it fell back. Self-tested on a synthetic census with 40 timer completions and zero socket completions: the old check accepted it, the new one rejects it.
…group D)
22 -> 21 edges. perry-ext-http -> h2 is gone; perry-ext-http -> tokio SURVIVES
and could not have moved in this lane.
The recorded blocker was stale for the third time today. It said
turnloop_tls_io exposed only a pub(crate) begin_client_upgrade taking
perry-ext-net's own TlsClientConfigData, so http2.connect('https://…') had no
way to install a client session. This tree has a PUBLIC install_client_session
taking neither of those objections, whose own doc names http2.connect as the
reason it exists, and turnloop_h2 has been calling it with h2 in ALPN since it
landed. No second surface was built.
What was actually left was two client-side h2 fallback sites, and deleting
them is better than keeping them: connect_h2_stream returned a bare
tokio::net::TcpStream and IGNORED the `secure` flag, so on that path an https:// URL
opened a cleartext socket and sent an HTTP/2 preface at a TLS listener — the
very surface the blocker described could not work there. It also built a
private current_thread runtime per connect AND a second per request, with an
Arc<Mutex<Option<SendRequest>>> that concurrent requests raced for.
An agent that cannot reach a loop now gets an 'error' from http2.connect and a
stream error from session.request(), following the perry-ext-ws precedent: a
real narrowing, written down rather than hidden behind a fallback nobody
exercises.
Two honesty points the lane made itself: the h2 PACKAGE stays in Cargo.lock via
hyper and reqwest, so the lockfile count is unchanged — what left is the direct
manifest edge and perry-ext-http's only own use of the crate — and it
explicitly declined to reroute through hyper::client::conn::http2 to make the
number move, which would have been ratchet-gaming. Verified: no added line
names h2:: or that path.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces tokio with turnloop — a host-driven, completion-shaped event-loop driver — across Perry's runtime, and measures the result against
main.Draft. tokio is not gone: 38 manifest edges across 12 crates, down from 46 across 16. Every remaining edge is enumerated with its own blocker, and the count is a required gate rather than a claim.
What lands
block_on(timeout(budget, notified))node:net— sockets on the loopchild_process, stdio pipes,dgram, signals as loop subscriptions rather than a self-pipe threadnode:http/node:httpsservers, TLS above the socketfetchandundiciMeasured
Quiet M1 mini, 5 interleaved rounds,
oha, ambient loadavg 1.67 before and 1.71 after. Two commits, not one commit with a feature flag: after P1–P7 thetokio-wait-driverfeature no longer selects "Perry on tokio", it selects turnloop with the wait driver swapped and the migrated subjects falling back to inline. Both arms verified live in opposite directions — the turnloop arm must print its[perry-loop] driver=turnloopmarker; the baseline arm must print no marker and no wait metrics.Threads, where the change is starkest:
tokio-rt-worker×16, which persisted after the work).pgclients each holding a 200 mspg_sleep: 13 → 1.perry verifyrun of the CLI: 66 → 2.turns=101 completions=282,tokio_ticks=0, on one thread.Memory, measured at the same scale on both arms after the ceiling fix below:
main)turnloop's idle floor is higher by 3.6 MB; the crossover is about 150 connections. Most of that gap is carrying both stacks at once — with no I/O at all the delta is +2.7 MB — so it should shrink as the remaining edges go.
These numbers were taken at
babc5f0d1f(P0–P7) and must be re-run at head before merge. P9, P11 and the GTK4 lane have landed since.Reproduce:
python3 scripts/turnloop/server_ab.py all --arm-tree tokio=<main tree> --arm-tree turnloop=<this tree>.Fixed here, found by measuring
A 2,048-connection ceiling. A turnloop server refused the 2,049th connection:
net_config()hardcodedmax_handles: 4096and a connection costs two handles. Sequential connects stopped at exactly 2,048, ruling out any backlog effect. The number was small because turnloop allocated it —Table::newbuilt every slot up front, 1,249 bytes per handle of ceiling across ten structures. turnloop 0.1.0-alpha.5 pages those tables, so a loop's idle cost is one page and byte-identical from 1K to 1M handles; the ceiling is now 65,536 handles and 10,000 of 10,000 connections open. (#10351)SO_REUSEPORTon every HTTP listener, and Nagle on every one of them.turnloop_serve::listenpassedserver.noDelayintotcp_listen'sreuse_portparameter. So a duplicatelisten()silently succeeded where Node givesEADDRINUSE— three servers on one port — andnoDelaynever reachedTCP_NODELAY. A bind that genuinely failed printed to stderr and hung, because nothing emitted'error'. Both halves fixed together; fixing either alone turns a wrong answer into a hang.fetch()inside aworker_threadsWorker. Workers never claimed an agent id, so they reportedPRIMARY_AGENTfrom a thread owning neither the primary heap nor its loop: the submit guard accepted the request andensure_loop_withrefused it a moment later. Verified 3/3 per arm —FAILED fetch failedbefore,status=200after.Correctness
Full gap suite, both arms built coherently, compared per test:
mainEvery lane ran the same comparison against its own base independently. Three of those nine are already red against the committed snapshot on
main, which is why the comparison is arm-to-arm rather than against the snapshot.14 new
node:http2conformance fixtures are included and all 14 fail today — recorded in the snapshot as the ratchet. They exist because Perry's HTTP/2 control surface is a loopback simulation:session.settings(),.goaway()and.ping()never encode a frame, they scan handles for the peer session in the same process and push a synthetic event. The existing tests pass because both ends are Perry. The new fixtures put a raw TCP socket on one end, which that peer scan cannot find.Why tokio is still here
scripts/tokio_inventory.py(new, in the requiredlintjob) gates the edge set in both directions — a new edge fails, and so does a stale entry. It is built fromcargo metadata, notcargo tree, becausecargo tree -i tokionames five crates here that have no edge and cannot seeperry-ui-gtk4's Linux-gated tokio from macOS.The 38 remaining, by plan group: database TLS (8), fastify's own hyper service (6), the servers group (4), WebSockets' tungstenite major mismatch (4), lettre's async transport (3), reqwest in
agent.rs(3),perry-container-compose(2), HTTP/2 (2), and the rest singly.async_bridge::RUNTIMEis last by construction — it is tokio because its clients hand it tokio futures.Not covered
Nothing has run on Windows. macOS covers the A/B build and run only.
cargo test --workspace, the auto-optimize gap tier, the node-compat matrix and the node-suite corpora have not been run on the merged branch. Thetokio-wait-driverswitch is inventoried but not deleted.For reviewers and for whoever lands this
Base commit for every number in this PR:
fcd108bfb0. PR #10382 (keepalive anchors,#[used]→#[used(compiler)], 567 sites) removes ~2.2 MB from essentially every binary — 10–50× the size signal here — so whichever of these lands first silently re-baselines the other. Any size comparison quoted against this PR must name its base.The public benchmark baseline must be regenerated after this merges, not before.
TCP_NODELAYnever reached the socket on the turnloop path (see the listen fix above), so every turnloop HTTP server has been running with Nagle enabled. Regeneratingbenchmarks/public-baseline-config.json's artifact beforehand would bake in Nagle-on latency. That file is inpublic_baseline.HARNESS_PATHS, so thelintstep stays red until it is regenerated (~2 h on the quiet mini).rustlsis untouched.0.23.43on both arms,aws-lc-sys 0.41.0on both, identical rustls version sets in both lockfiles. RUSTSEC-2026-0285 is unaffected by this PR in either direction and remains a separate policy decision.This should land as its own train. 340 files and 100 commits should not share blame with unrelated work; at this size a bisect inside a mixed train is the only recourse.
Reading the gap-suite result precisely. The comparison is arm-to-arm, per test, on failure sets rather than counts. "Zero status changes" therefore means no test that was passing on the base is failing on the branch. It cannot speak for tests already failing on the base for unrelated reasons — three are currently red on
mainagainst the committed snapshot (iterator_prototype_next_patch,2899_2779_2777_static_helpers,disposablestack_2875), and they are red on both arms here.run-extended-testsis set on this PR deliberately. Neither Windows job runs in the PR tier, so without it a change touching the CLI, the GTK4 backend and the whole runtime would go green onpr-gatehaving never been compiled on Windows. A manual Windows validation work order is also running: #10385.Current state
34 tokio manifest edges across 12 crates, down from 46 across 16. The count is a required
lintgate (scripts/tokio_inventory.py), fails in both directions, and is built fromcargo metadatarather thancargo tree— which names five crates here that have no edge, and cannot seeperry-ui-gtk4's Linux-gated tokio from macOS.Landed since the first draft of this description:
node:http2onturnloop-http's sans-I/O core. h2spec 147/147 against Perry's own server — the hyper arm it replaces scores 146, because hyper drops the connection on an invalid client preface without sending the GOAWAY it owes.settings()/goaway()/ping()encode real frames for the first time; they were a loopback simulation that scanned process handles and never touched the wire.tokio-tungstenitegone from the lockfile entirely; three tungstenite majors down to two. The stated blocker — "the handshake needs an owned stream" — was wrong: what needed one wastokio_tungstenite::WebSocketStream'sAsyncRead + AsyncWritebound, an API shape of that crate.tls-server-end-point(the server recomputes the digest inside the SASL exchange, so authenticating is its verdict), with an untrusted-CA control so the result isn't vacuous. Fixed two things that had never worked:new Redis()(defaults to TLS, declined onto a path with no TLS backend) andhttp2.connect('https://…')(parse_authorityreturned port 80 for every scheme).noDelaysat inreuse_port's argument slot, so every turnloop HTTP listener ran withSO_REUSEPORTon and Nagle on, and a duplicatelisten()silently succeeded where Node givesEADDRINUSE— three servers on one port. A genuine bind failure printed to stderr and hung. Both halves fixed together; either alone turns a wrong answer into a hang.What the remaining 34 actually are
Roughly 15 are ordinary migration work — fastify and its framework server (9), the last reqwest and lettre client edges (6). Both have substantial work in progress on
turnloop/fastify-wipandturnloop/clients-wip.The other ~8 are not migration, and saying so plainly matters more than the number:
Loop::poster() -> Poster, aSendhandle for exactly this, and Perry does not use it. Whether a pump thread should post to the owning agent's loop instead of keeping a tokio socket task is the open question.perry-container-composeis a 14.8k-line non-JS tool with no event loop involved. Does it count as Perry?async_bridge::RUNTIMEis tokio because its clients hand it tokio futures.So the floor without those decisions is about 3–4 edges, not zero.
Known defects, open and tracked
WebSocketServer: an external client that closes first sees 1006; the echoed close frame never leaves. All four WS fixtures are byte-identical to Node and still miss it, because in each the peer is Perry.test_gap_http2_wire_*fixtures print complete, correct output and do not exit — the loop is not draining after a control-surface op against a non-Perry peer. Recorded in the snapshot; diagnosis unfinished.fetchand SMTP share this).session.settings().Still not covered
Nothing has run on Windows; a manual validation work order is in flight (#10385).
cargo test --workspace, the auto-optimize gap tier and the node-compat matrix have not been run on the merged branch. The A/B numbers above predate P9, P11, WebSockets and TLS and must be re-run at head before this merges.