Skip to content

turnloop: replace tokio as Perry's event loop (P0–P8) - #10354

Draft
proggeramlug wants to merge 236 commits into
mainfrom
turnloop/integration
Draft

proggeramlug wants to merge 236 commits into
mainfrom
turnloop/integration

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

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

lane what moved
P0 the wait driver: a turnloop loop instead of block_on(timeout(budget, notified))
P1 node:net — sockets on the loop
P2 child_process, stdio pipes, dgram, signals as loop subscriptions rather than a self-pipe thread
P3 timers on the loop's own deadline, not a millisecond-truncated budget
P4 the blocking pool, and perry-ffi ABI v2
P5 node:http/node:https servers, TLS above the socket
P6 global fetch and undici
P7 MySQL, PostgreSQL, Redis, MongoDB
P8 the tokio inventory, as a gate
P9 a turnloop loop per JS agent
P11 the CLI, axios, node-fetch
the Linux GTK4 tray and MPRIS, off tokio without losing either

Measured

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 the tokio-wait-driver feature 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=turnloop marker; the baseline arm must print no marker and no wait metrics.

scenario throughput p50 p99 p999
1 connection +23.9 % −19.4 % −16.2 % −16.7 %
64 connections +25.2 % −22.4 % −17.7 % −20.7 %
1024 connections +25.9 % −21.9 % −12.7 % −20.8 %

Threads, where the change is starkest:

  • 16 Redis clients with a command in flight: 17 → 1 (tokio-rt-worker ×16, which persisted after the work).
  • 12 pg clients each holding a 200 ms pg_sleep: 13 → 1.
  • One perry verify run of the CLI: 66 → 2.
  • An HTTP server measured 0 turnloop turns at P0; it now runs turns=101 completions=282, tokio_ticks=0, on one thread.

Memory, measured at the same scale on both arms after the ceiling fix below:

tokio (main) turnloop
10,000 idle connections 321,760 KiB 110,940 KiB
bytes per connection 30,864 6,228 (−80 %)
idle HTTP server 46,544 KiB 50,116 KiB (+3.6 MB)

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() hardcoded max_handles: 4096 and 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::new built 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_REUSEPORT on every HTTP listener, and Nagle on every one of them. turnloop_serve::listen passed server.noDelay into tcp_listen's reuse_port parameter. So a duplicate listen() silently succeeded where Node gives EADDRINUSE — three servers on one port — and noDelay never reached TCP_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 a worker_threads Worker. Workers never claimed an agent id, so they reported PRIMARY_AGENT from a thread owning neither the primary heap nor its loop: the submit guard accepted the request and ensure_loop_with refused it a moment later. Verified 3/3 per arm — FAILED fetch failed before, status=200 after.

Correctness

Full gap suite, both arms built coherently, compared per test:

main this branch
parity failures 9 the same 9
compile / crash 0 / 0 0 / 0
status changes none

Every 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:http2 conformance 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 required lint job) gates the edge set in both directions — a new edge fails, and so does a stale entry. It is built from cargo metadata, not cargo tree, because cargo tree -i tokio names five crates here that have no edge and cannot see perry-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::RUNTIME is 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. The tokio-wait-driver switch 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_NODELAY never reached the socket on the turnloop path (see the listen fix above), so every turnloop HTTP server has been running with Nagle enabled. Regenerating benchmarks/public-baseline-config.json's artifact beforehand would bake in Nagle-on latency. That file is in public_baseline.HARNESS_PATHS, so the lint step stays red until it is regenerated (~2 h on the quiet mini).

rustls is untouched. 0.23.43 on both arms, aws-lc-sys 0.41.0 on 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 main against the committed snapshot (iterator_prototype_next_patch, 2899_2779_2777_static_helpers, disposablestack_2875), and they are red on both arms here.

run-extended-tests is 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 on pr-gate having 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 lint gate (scripts/tokio_inventory.py), fails in both directions, and is built from cargo metadata rather than cargo tree — which names five crates here that have no edge, and cannot see perry-ui-gtk4's Linux-gated tokio from macOS.

Landed since the first draft of this description:

HTTP/2 node:http2 on turnloop-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.
WebSockets tokio-tungstenite gone from the lockfile entirely; three tungstenite majors down to two. The stated blocker — "the handshake needs an owned stream" — was wrong: what needed one was tokio_tungstenite::WebSocketStream's AsyncRead + AsyncWrite bound, an API shape of that crate.
TLS on a turnloop socket A public, binding-agnostic client installer. Postgres SCRAM-SHA-256-PLUS proven with 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) and http2.connect('https://…') (parse_authority returned port 80 for every scheme).
Listener bugs noDelay sat in reuse_port's argument slot, so every turnloop HTTP listener ran with SO_REUSEPORT on and Nagle on, and a duplicate listen() silently succeeded where Node gives EADDRINUSE — 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.
Connection ceiling 2,048 → 10,000 of 10,000 open, at 6,228 bytes per connection against tokio's 30,864, measured at the same scale on both arms.

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-wip and turnloop/clients-wip.

The other ~8 are not migration, and saying so plainly matters more than the number:

  • Group A (4) needs a design decision, not code. Its edges are reached only by "a thread that could not get a loop of its own — since turnloop P9 that is a second thread acting for an agent another thread already owns (a host pump thread; Android's UI thread)". turnloop ships Loop::poster() -> Poster, a Send handle 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.
  • Group K (2) is a policy call: perry-container-compose is a 14.8k-line non-JS tool with no event loop involved. Does it count as Perry?
  • Group L (1) falls automatically — async_bridge::RUNTIME is tokio because its clients hand it tokio futures.
  • Group N (1) is not a tokio edge at all; it pins a tungstenite major.

So the floor without those decisions is about 3–4 edges, not zero.

Known defects, open and tracked

  • Attached WebSocketServer: an external client that closes first sees 1006, the echoed close frame never leaves #10391 — attached 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.
  • Five 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.
  • turnloop#48 — no TLS client-certificate constructor, so no turnloop TLS client in Perry can present one (fetch and SMTP share this).
  • turnloop#87 — SETTINGS is a constructor argument rather than a negotiation; six gaps behind a fully Node-compatible 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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two fixes and the gap coverage for the P1 transport.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also adds the P2 half of the `PERRY_LOOP_STATS=1` exit line. The existing
`completions=` cannot distinguish a socket P1 carried from a child pipe
P2 carried, and every live count is zero by the time a process exits, so
the line reports the lifetime adoption count alongside the live one —
which is what an A/B or an acceptance test reads to know the threads were
really replaced rather than merely not used.
Ralph Küpper and others added 2 commits September 16, 2026 19:01
Ralph Küpper and others added 8 commits September 16, 2026 22:01
…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.
Ralph Küpper 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant