Skip to content

Kernel: rust-first migration — unified ABI-44 tree (transport, native host, VFS, fork/exec) - #1350

Draft
brandonpayton wants to merge 260 commits into
mainfrom
brandonpayton/epoll-kernel-route
Draft

Kernel: rust-first migration — unified ABI-44 tree (transport, native host, VFS, fork/exec)#1350
brandonpayton wants to merge 260 commits into
mainfrom
brandonpayton/epoll-kernel-route

Conversation

@brandonpayton

@brandonpayton brandonpayton commented Sep 1, 2026

Copy link
Copy Markdown
Member

Why

Kandelo splits POSIX and ABI responsibility across two languages: the Rust
kernel owns authoritative state, but a large amount of version-sensitive and
semantic behavior (syscall marshalling, readiness/blocking, VFS, fork/exec
continuation) still lived in TypeScript. That is "required host
implementation": every host — Node, the browser, and a future native
wasmtime host — has to re-implement it, and none of it gets Rust's deeper
typechecking. The rust-first campaign moves that logic into the Rust kernel
so all hosts share one implementation and the host API surface each must
provide shrinks to genuine device/engine capabilities.

The decision rule and the full remaining-work plan live in
docs/plans/2026-09-04-rust-first-remaining-purpose-framed.md.

What changed

This PR is the unified ABI-44 tree for the campaign, rebased on current
main. It brings together work that had been developed on separate
branches:

  • Phase 2 — opaque syscall transport. The host no longer interprets the
    guest syscall ABI for dispatch; it consumes a generated contract and
    routes to kernel_handle_channel.
  • Phase 3 — native wasmtime host (crates/host-native): the same
    kernel.wasm can run outside Node/browser, proving the boundary is not
    secretly JavaScript-shaped.
  • Phase 4 — kernel-owned blocking/readiness. poll/select/epoll readiness
    is computed by the kernel; the host keeps only the wait/retry loop and the
    Atomics.wait primitive.
  • Phase 5 — in-kernel VFS. tmpfs + a rootfs overlay are the
    unconditional / authority (path resolution, metadata, permissions, COW);
    the host serves only immutable bytes via blob_read/fetch_archive.
  • Phase 6 — fork/exec into Rust. A fork-codec crate + a co-resident
    Rust fork-module drive fork continuation; exec/spawn authority moved into
    the kernel; reference kinds no package uses fail loud with EOPNOTSUPP
    rather than silently falling back.

ABI_VERSION is 44. It is still in development — main is 43, so 44 is
unreleased; this tree is the canonical 44.

Reconciliation

The transport line and the fork line had diverged, each independently
stamping ABI 44 with a different abi/snapshot.json. They were reconciled by
rebasing the fork line onto the transport line, then onto latest main
(picking up main's build fixes). The history is linear; one canonical ABI-44
snapshot was regenerated from the merged source. Conflicts were few and
resolved on their merits: where main had independently landed the official
version of a fork prototype (e.g. the coreutils-docs source-only fix, #1352),
main's version was preferred; additive changes were unioned; and one genuine
merge combined main's fail-loud kernel resolution (#1358) with the fork
line's tar→ZIP test migration.

Progress since reconciliation

On top of the reconciled tree, the campaign has continued through the
subagent-driven plan — each increment implemented, task-reviewed,
whole-increment-reviewed, then pushed:

  • F1 — fork abort-replay into Rust. When fork-continuation replay fails,
    the abort/rollback path is now driven by the fork-codec / fork-module
    Rust with a minimal host seam, closing the last supported-path fork logic
    that still lived in TypeScript.
  • N1 — native wasmtime backend (crates/host-native), the campaign's
    forcing function: it boots the real kernel.wasm on wasmtime with the same
    kernel exports and host imports as Node and the browser, so any reducible
    host logic shows up as a divergence.
    • I1 — sandboxed in-memory VFS as the native default. The native host
      defaults to a virtual in-memory / (in-kernel overlay + tmpfs), never
      the real host FS; real directories are reachable only via an explicit
      mount, at parity with the Node host's HostFileSystem.
    • I2 — real in-memory base image. The host serves real base-file
      content at / via an RTFS manifest plus a host_blob_read-backed blob
      map (no heavy rootfs.vfs build), so a guest reads shipped base files.
    • I3a — native posix_spawn + waitpid. The pump now runs N
      processes: a parent spawns a child, the child runs in its own process,
      and the parent reaps its exit status through a parked host_waitpid that
      never blocks the single-threaded pump. It drives the kernel's spawn/reap
      authority directly (no native-only shortcut), holding Node/browser
      parity. 17/17 host-native tests.

As intended, the native path is already surfacing platform follow-ups:
runtime-core's blocked-retry table has no wait4 entry (worked around
host-side for now), and native process-lifecycle needs
orphan-reparent-to-init so a parent can exit while a child still runs. Both
are recorded in the plan doc.

Status

Draft — reconciliation complete and self-consistent; the campaign is
advancing through the native backend (N1). F1 and native increments
I1 / I2 / I3a are landed and reviewed.

  • Merged Rust workspace compiles; the ABI-44 snapshot regenerates cleanly
    from source. A HOST_RAW_SYSCALLS audit confirms the opaque-record
    fast-path and the in-kernel VFS's host-callback / EAGAIN machinery do not
    collide (the byte-serving FS syscalls are RAW and stay off the fast-path).
  • The host-native test suite runs green (17/17).
  • Broader validation (host Vitest, browser Playwright) on the isolated build
    cache remains deferred per the agreed plan until a build is otherwise
    warranted.

Remaining campaign work — the native fork increments (I4 fork frames, a
user checkpoint; I5 fork references), native conformance suites (I6),
ABI-44 finalization, and the Phase 7 freeze gate — is tracked in
docs/plans/2026-09-04-rust-first-remaining-purpose-framed.md.

@brandonpayton brandonpayton changed the title Kernel: Move the VFS into the Rust kernel (rust-first migration, Phase 5 — WIP) Kernel: rust-first migration — VFS (Phase 5) + fork/exec (Phase 6 D1-D4, WIP) Sep 1, 2026
brandonpayton and others added 29 commits September 4, 2026 13:21
…ys raw (ABI 44, Phase-2 Option A)

The guest self-marshals a bounded record for non-blocking, non-intercepted
syscalls; the host transports it blindly to the kernel decoder. The whole
host-blocking-managed set + all Tier-A capability intercepts stay on raw args
(Option A) — blocking I/O flips in Phase 4 when readiness moves to Rust.

- Authoritative RAW set in crates/shared/src/host_raw_syscalls.rs (76 entries;
  union of capability + every host-blocking-managed syscall), generated to the
  guest C header and host/src/generated/abi.ts, cross-checked against
  GENERIC_BLOCKING_SNAPSHOT_SYSCALLS so no blocking syscall is ever missed.
- Record indicator is a per-syscall header flag REQUEST_FLAG_OPAQUE_RECORD
  (bit 3), NOT CH_DATA magic — CH_DATA is reused/inherited across fork, so a
  stale magic could misroute a RAW syscall.
- Guest: __do_syscall_impl marshals when !is_raw and the record has >=1 span;
  __unmarshal_channel_record copies OUT/InOut span results back to caller
  pointers (flat only — nested/blocking are RAW in Phase 2).
- Host: RECORD-flag fast-path in #handleSyscallInner does blind transport and a
  loud guard fails a RAW syscall that carries the flag (never silent deadlock).
- Kernel unchanged (already dual-paths on RECORD_MAGIC). ABI_VERSION 43->44;
  3 of 9 dump-abi artifacts changed.

VALIDATION STATE (checkpoint, not yet merge-ready): build/musl/kernel/host all
clean; check-abi-version.sh RC=0 (ABI 44 consistent); cargo test --workspace
green; host Vitest 3823 passed / 118 failed (mostly rootfs/package-system env),
guard never fired. OPEN: 4 signal/timer tests (select-signal-guest,
posix-timer-thread) show real assertion failures on RAW paths — base comparison
pending to attribute pre-existing vs regression. Browser parity + benchmarks +
multi-threaded futex validation still owed (human environment).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…leak EAGAIN

The Phase-2 opaque-record flip routes every non-RAW syscall through the
host's blind record fast-path, which does no host post-processing at all
-- no EAGAIN park/retry, no peer wakeups. That path is only safe for
non-blocking, purely-marshalling syscalls, so the RAW set must contain
every host-involved or blocking syscall.

The RAW set was cross-checked only against
GENERIC_BLOCKING_SNAPSHOT_SYSCALLS, but that is not the whole blocking
surface. `sigsuspend` and `pause` block through a distinct host park,
`host_sigsuspend_wait` (see `sys_sigsuspend`), and were in neither set.
`sigsuspend` carries a signal-mask pointer, so the guest marshalled it
onto the record fast-path; the kernel returned its blocking EAGAIN and
the fast-path handed that EAGAIN straight back to libc instead of
parking. sigsuspend then returned EAGAIN where POSIX requires it to
suspend until a signal (observed as the select-signal nested-wait
kind=3 failure). Add both to the authoritative RAW set (regenerating the
guest marshal header and host abi.ts) and document the second blocking
class so the completeness contract stays truthful. `pause` takes no
pointer and never reached the record path, but it is host-blocking-
managed and is kept RAW for the same contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… --stamp-abi-version

The fork-continuation browser/Node tests (CatchRef, reference-bearing catches,
aliased WasmGC reconstruction in a fresh child worker) run hand-authored .wat
fork artifacts through the real host, which refuses any fork artifact whose
__abi_version != the current epoch (the continuation-replay format is
ABI-versioned). The fixtures previously hardcoded __abi_version = 43, so the
ABI 43->44 flip made the host reject them before the real reconstruction
assertions could run — a stale-fixture failure, not a fork/GC regression.

These fixtures can't get __abi_version the way real programs do (the libc glue
+ linker export it): they're standalone .wat that exercise Wasm GC / exnref /
typed-refs C can't express, and fork-instrument only stamps a missing marker
for side modules, not standalone executables. So instead of regenerating the
fixtures every ABI bump, add a test-only affordance:

- fork-instrument gains an opt-in `--stamp-abi-version` flag
  (Options.stamp_abi_version, default false; force_stamp_abi_version rewrites
  the __abi_version export to the instrumenter's current ABI_VERSION just
  before emission). It is documented as test-only: production artifacts must
  get the marker from the toolchain, and a missing/mismatched marker on a real
  artifact stays a truthful host rejection.
- The three fixtures now declare a clearly-non-ABI placeholder sentinel
  (i32.const 999999999) with a comment, so no reader mistakes it for a real
  epoch; the flag overwrites it with the current ABI at instrument time, so the
  fixtures auto-track every future ABI bump with no manual step.
- The test call sites pass the flag; the reconstruction assertions (exitCode 0
  = GC/reference identity rebuilt) are unchanged and remain the correctness
  proof. A dedicated negative test instruments WITHOUT the flag and asserts the
  host rejects the sentinel as stale, keeping the ABI-staleness gate exercised.

Validation: gc + catch-ref Node tests pass (incl. the negative test);
fork-continuation.spec.ts 5/5 in headless Chromium; cargo test --workspace
--exclude xtask 2047/0; check-abi-version.sh RC=0 (no ABI input touched).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… + prove the channel handshake

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 3, increment 2 of the rust-first runtime roadmap. Increment 1 proved
Wasmtime can load the real ABI-44 kernel.wasm and drive the atomic
wait/notify channel primitive; this closes the loop by running a real
program through the real syscall channel with no browser, no Node, and no
JavaScript.

## Why

The freeze-gate acid test for the rust-first runtime is that the platform
boundary is not secretly JavaScript-shaped: the same kernel.wasm, the same
ABI, and the same channel must run on a third, non-JS engine. Increment 1
loaded the kernel; it could not yet *run* anything. Without a native host
that actually boots the kernel and services syscalls, "the channel works on
Wasmtime" was an assertion about a handshake, not about a program. This
increment makes it an assertion about a program.

## What changed

`crates/host-native` gains `guest.rs`: it creates a process in the kernel,
lays out the process memory exactly like the TypeScript host's
computeProcessMemoryLayout, instantiates a real SDK-built guest on its own
OS thread over a second shared memory, and runs the host-side channel pump
that carries each syscall the guest posts into kernel_handle_channel and the
result back.

The pump implements the real spine, keyed off wasm-posix-shared (the same
ABI constants the kernel and guest glue use, never hand-copied numbers):

  - the two-thread wait/notify handoff on the channel status word (host
    polls PENDING, release-stores COMPLETE, then SharedMemory::atomic_notify
    unparks the guest's wait32);
  - RAW pointer-arg marshalling driven by SYSCALL_ARG_DESCRIPTORS: it stages
    write(2)'s buffer into the kernel scratch DATA region and rewrites the
    arg to the absolute kernel address, with a loud guard against the
    opaque-record magic and a loud error on any descriptor form this
    increment does not implement;
  - anonymous-mmap address-space growth (grow the guest memory to cover the
    returned mapping before the guest resumes), mirroring growMemoryToCover;
  - the minimal native host_* capabilities the boot + trivial path needs
    (host_write routed to captured stdout/stderr, host_is_thread_worker,
    host_close, plus defensive clock/random/debug_log); every other host_*
    import stays a trap, a truthful boundary that surfaces surprises;
  - exit handling: the kernel dispatches exit_group by committing the status
    and calling its kernel_exit export, which ends in `unreachable`, so a
    trap on an exit syscall is the expected successful end of the run.

The guest fixture native_hello.c (getpid + write + return) is built through
the SDK exactly like scripts/build-programs.sh builds the example programs;
its ABI marker must match the kernel or the load fails loudly. The committed
.wasm keeps the test dependent only on a built kernel.wasm.

The smoke test asserts the full observed syscall path
(mmap, set_tid_address, getpid, write, exit_group), exit code 0, and that
the guest's stdout arrives through host_write — proving the program ran, not
merely that it exited.

Validation: scripts/dev-shell.sh cargo test -p host-native
--target aarch64-apple-darwin -> 3 passed. Host-only crate; the wasm kernel
build never compiles it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 3, increment 3. Increment 2 ran RAW syscalls (write, getpid, mmap)
through the native host; this adds the opaque-record blind-transport path so
the syscalls the Phase 2 flip moved to guest self-marshalling — uname,
getcwd, stat, and the rest of the metadata family — run natively too.

## Why

The rust-first roadmap's freeze gate is that the platform boundary is not
secretly JavaScript-shaped: the same kernel.wasm, the same ABI, and the same
channel — including the Phase 2 opaque transport — must run on a third,
non-JS engine. Increment 2 proved the RAW path on Wasmtime, but the opaque
record is the more interesting half of the flip: the guest, not the host,
marshals the pointer arguments, and the host transports the byte region
blindly. Until that path runs on Wasmtime, "the opaque transport works" was
only an assertion about the browser and Node hosts. This makes it an
assertion about a non-JS engine.

## What changed

The channel pump now reads REQUEST_FLAG_OPAQUE_RECORD from the channel header
(never the data-buffer magic, which can be stale across fork or a reused
channel slot — matching kernel-worker.ts) and branches:

  - record path: stamp the syscall number, blind-copy the guest's whole data
    region into the kernel scratch, dispatch (the kernel's
    prepare_channel_record decodes the record, validates it, dispatches, and
    writes OUT/InOut results back into the record at their span offsets),
    then blind-copy the data region back so the guest's
    __unmarshal_channel_record delivers them to the caller's pointers. No
    descriptors, no arg rewriting, no mmap growth — the record is
    authoritative for both scalars and pointer spans. The scratch record
    magic is cleared afterward so a later RAW scalar syscall reusing the
    scratch is not misdecoded.
  - RAW path: unchanged from increment 2.

The increment-3 fixture native_uname.c calls uname(2) — a non-RAW syscall
that self-marshals its struct-utsname pointer as an Out span — and prints
sysname. The smoke test asserts the output is the kernel's compiled-in
"wasm-posix", which is only correct if every link in the round-trip
(guest marshal, host blind transport, kernel decode + struct writeback, host
blind copy-back, guest unmarshal) works; an empty or garbled sysname would
fail it.

Validation: scripts/dev-shell.sh cargo test -p host-native
--target aarch64-apple-darwin -> 4 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 3, increment 4. Increments 2-3 covered RAW scalar/In syscalls and the
record-path Out; this adds the one marshalling direction still untested — a
RAW syscall with an Out pointer buffer the kernel fills and the host must
copy back into the guest — plus in-kernel pipe I/O.

## Why

The native host is the freeze-gate conformance engine: it must marshal every
argument shape the browser and Node hosts do, or "the channel works on
Wasmtime" is only a claim about the shapes the earlier fixtures happened to
use. `write` is In-only and `uname` is a record-path Out, so the RAW Out
copy-back — the path `read`, `recv`, and `getdents` all depend on — had no
native coverage. A gap there would silently drop every buffer the kernel
returns to a RAW syscall.

## What changed

The fixture native_pipe.c does a pipe round-trip entirely in-kernel:
pipe(fds) (record path, the two fds returned via an Out span), write into
the pipe (RAW In), read back (RAW Out: the kernel copies the piped bytes into
the kernel scratch and the pump copies them into the guest buffer), then
write the bytes to stdout. The write precedes the read and the message is far
smaller than the pipe buffer, so neither blocks — the pump's non-blocking
assumption holds. The smoke test asserts the round-tripped line and that a
read syscall is in the trace.

Also adds fixtures/build-fixtures.sh, which rebuilds every *.c fixture with
the exact scripts/build-programs.sh recipe (verified to reproduce the
committed native_hello.wasm and native_uname.wasm byte-for-byte), so
regenerating fixtures is one command instead of a hand-copied clang line.

Validation: scripts/dev-shell.sh cargo test -p host-native
--target aarch64-apple-darwin -> 5 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 3, increment 5. Increments 2-4 ran syscalls that stay inside the
kernel (mmap, getpid, pipe, uname). This one reaches the host filesystem:
the guest open()s and read()s a real host-served file, exercising the FS
host capabilities the browser and Node hosts use as the kernel's root
filesystem.

## Why

Every filesystem syscall a real program makes — open, read, stat, and the
rest — resolves against the host through host_lstat/host_open/host_pread.
Until those capabilities run on the native host, "the channel works on
Wasmtime" said nothing about the filesystem path, which is most of what a
ported program does. A guest cannot even resolve a path without them: the
kernel probes the host to resolve "/", so with no host filesystem present,
open() of any path — even a devfs node — fails because the root does not
exist. The native host was, until now, a machine with no root filesystem.

## What changed

The pump's RAW marshaller learns the CString argument size: open/openat/
access are RAW syscalls whose path is a nul-terminated string, so the host
scans guest memory for the NUL and stages the whole path (this was the one
marshalling size form the earlier fixtures never hit).

The native host gains a minimal single-file root filesystem (crates/
host-native/src/guest.rs, HostFs): it serves "/" as a directory and one
regular file "/native.txt", implementing host_lstat/host_stat (writing the
repr(C) WasmStat the kernel reads back), host_open (handing out a host
handle), host_pread (the read path the kernel actually uses for host files,
since the kernel owns the offset), host_fstat, and host_close. Every other
path is absent (-ENOENT), which sends the kernel to its internal namespaces.

The fixture native_hostfs.c opens "/native.txt", reads it, and echoes it to
stdout; the smoke test asserts the host-served contents come back and that an
open syscall appears in the trace. This is the first fixture to touch a real
(host-backed) file rather than staying kernel-internal.

Validation: scripts/dev-shell.sh cargo test -p host-native
--target aarch64-apple-darwin -> 6 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4, increment 1. Phase 3 ran only non-blocking syscalls on the native
host; its pump completed every channel with whatever kernel_handle_channel
returned, including EAGAIN. That is wrong for a blocking syscall — the guest
would see EAGAIN instead of blocking. This adds the host wait capability that
the rust-first design keeps host-side: the readiness *decision* stays in the
kernel, the *waiting* is the host's job.

## Why

Phase 4 of the roadmap moves blocking/readiness ownership: the kernel decides
whether an operation is ready (it already does — sys_poll/sys_read return a
result or EAGAIN), and the host provides only a wait(timeout)/wake capability
that re-checks readiness until the op completes. On the native host that
capability did not exist, so no blocking syscall could work. Standing it up
first, against the *existing* kernel retry-token protocol, validates that
protocol on a third (non-JS) engine and is the abstraction the later
kernel-side readiness moves must preserve.

## What changed

The pump learns the kernel's retry-token protocol (crates/host-native/src/
guest.rs). When a blocking RAW syscall returns EAGAIN, the host:

  - calls kernel_blocking_retry_token(pid, tid, syscall_nr) — 0 for a
    host-only-snapshot syscall like poll (no target to pin), a positive token
    for a syscall whose stable target (an OFD) must be re-bound across retries;
  - re-dispatches kernel_handle_channel under that token (re-binding the tid
    each time, since a dispatch consumes the binding), letting the kernel
    re-decide readiness on every attempt;
  - owns the timeout deadline: sys_poll does not track elapsed time, so on the
    deadline the host rewrites poll's timeout arg to 0 in the scratch and does
    one final non-blocking dispatch, which makes the kernel return 0 (timed
    out) instead of EAGAIN;
  - releases a positive token with kernel_blocking_retry_release afterward.

This increment handles the smallest blocking op: poll(NULL, 0, N ms) — token
0, no readiness sources, no cross-process concurrency, pure timeout path.
Readiness-driven waits (a read woken by another task's write) need the kernel
wake-event drain and cross-task concurrency and come in a later increment; a
30s hard cap turns any protocol bug into a bounded error rather than a hang.

The fixture native_poll.c polls with a 60ms timeout; the smoke test asserts it
returns 0 (not EAGAIN) and that real time actually elapsed, proving the host
waited rather than returning immediately.

Validation: scripts/dev-shell.sh cargo test -p host-native
--target aarch64-apple-darwin -> 7 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…host

Phase 4, epoll readiness (de-risking the "readiness decision to Rust" move).
The browser/Node host is the one place epoll readiness is still reimplemented
in TypeScript: handleEpollPwait converts epoll_pwait to a host-built poll and
never calls the kernel's sys_epoll_pwait, a documented workaround for a Chrome
V8 crash in kernel_handle_channel for that syscall. Before moving that decision
back into the kernel for the JS hosts, we need to know the kernel's own epoll
path is actually sound when driven through the real channel.

## Why

Deleting the TS bypass is risky precisely because the reason it exists is a
crash, not a design choice — so the first safe step is to prove sys_epoll_pwait
works end to end over the channel on an engine that does NOT have the V8 bug.
The native Wasmtime host is exactly that engine: its pump dispatches whatever
the guest posts straight to kernel_handle_channel, with no epoll special-casing
and no poll conversion. A green epoll run here means the kernel path is correct
and the remaining browser work is a V8-workaround problem, not a kernel one.

## What changed

The native pump's RAW marshaller learns epoll's pointer args, which have no
entry in SYSCALL_ARG_DESCRIPTORS because the JS hosts special-case epoll
(crates/host-native/src/guest.rs, arg_descriptors): epoll_ctl's event at arg3
(a 16-byte epoll_event — events u32 @0, data u64 @8, matching the kernel's
WasmEpollEvent) and epoll_pwait's events array at arg1 (Out, maxevents*16) plus
its optional sigmask at arg4. These mirror exactly what the kernel dispatch
reads from the channel scratch.

The fixture native_epoll.c makes a pipe readable, registers EPOLLIN via
epoll_ctl, and calls epoll_wait; the kernel's sys_epoll_pwait detects the
readable pipe and returns it. The smoke test asserts the readiness result comes
back and that epoll_ctl and epoll_pwait actually appear in the trace — i.e. they
were routed to the kernel, not converted to a host poll.

Validation: scripts/dev-shell.sh cargo test -p host-native
--target aarch64-apple-darwin -> 8 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4 (B.1). The native pump was a single-channel loop whose blocking-retry
waited *in place*: it re-dispatched one syscall until it completed, parking the
one thread that drives the kernel. That is correct for a single-threaded guest
but deadlocks the moment a second thread exists — a thread blocked on read would
monopolize the pump, so the thread whose write would make the read ready never
gets serviced. This restructures the pump into the event loop that a concurrent
guest requires, with no behavior change yet.

## Why

The kernel Store is not Sync, so exactly one OS thread may call
kernel_handle_channel; and that thread must never block on a single channel
while another channel holds the key to unblock it. The only structure that
satisfies both is a single-threaded loop that services every channel and turns
a would-block syscall into a *parked* entry retried across iterations, rather
than a nested wait. This commit builds that structure while there is still only
one channel, so the change is a pure refactor validated by the existing tests
before concurrency is introduced.

## What changed

run_pump is now an event loop over a list of channels and a table of blocked
ops (crates/host-native/src/guest.rs). Each iteration it (1) re-dispatches every
parked op under its retry token — completing it when ready or, for a timeout op,
on its deadline — and (2) services each channel's newly posted request, parking
a would-block syscall instead of looping on it. A channel whose request is
already parked is skipped so it is not double-dispatched (which would leak a
second retry token). Dispatch, staging, and completion are factored into
dispatch_once / stage_raw / complete_channel / bind_and_dispatch helpers shared
by the fresh-dispatch and retry paths; the old in-place blocking_wait is gone.

Behavior is unchanged: the single main channel is the only channel, and the
poll-timeout op now parks-and-retries through the table instead of a nested
loop. All eight existing tests pass, including the poll-timeout wait.

Validation: scripts/dev-shell.sh cargo test -p host-native
--target aarch64-apple-darwin -> 8 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4 (B.2). B.1 restructured the pump into an event loop; this exercises the
readiness-driven blocking path through it — a read that would block, parks with
a retry token, and completes when the host makes it ready — on a single channel,
isolating that path before the two-thread test adds concurrency.

## Why

The poll-timeout wait (increment 1) ends on the clock; a readiness-driven wait
(read/accept) ends only when some external actor makes the fd ready, and it
takes a positive retry token that pins the exact open file across the gap where
the guest was parked. That token acquire/park/retry/release lifecycle had no
native coverage. Validating it on one channel — with the host itself as the
readiness source — de-risks it before the harder two-thread case, where a bug
would surface as a hang rather than a clear failure.

## What changed

The pump now treats read as a syscall that can block (syscall_can_block), so an
EAGAIN read is parked with a retry token and re-dispatched (blocking_deadline
returns None for it: it waits indefinitely, capped by the 30s safety cap, rather
than on a timeout). The native host serves fd 0 (stdin, a HostPipe) as a
blocking source: host_read(0) returns EAGAIN on the first call — forcing the
kernel to block and the pump to park the read — then delivers one line, then
EOF, exactly how a real host pipe behaves when input arrives on a later poll (a
call counter makes it deterministic). The two host_read paths (blocking stdin
and the served-file cursor) are merged into one import.

The fixture native_stdin.c reads stdin and echoes it; the smoke test asserts the
read completes with the line the host delivered after the read had already
blocked — proving the park/retry/token path end to end.

Validation: scripts/dev-shell.sh cargo test -p host-native
--target aarch64-apple-darwin -> 9 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4 (B.3), the payoff. B.1 made the pump a multi-channel event loop and B.2
proved a readiness-driven blocking read on one channel. This adds a second guest
thread so a blocking read is woken by a writer thread — the scenario the event
loop exists for, and the one that would deadlock the old in-place blocking loop.

## Why

A blocked read must not monopolize the single thread that drives the kernel, or
the writer thread whose write would make the read ready could never run. This is
the concrete demonstration that the native host handles real concurrency: the
kernel decides readiness, the host owns the waiting, and the one kernel-driving
thread fairly interleaves both channels so neither starves.

## What changed

The native host gains pthread support:

  - kernel_clone: pthread_create calls this import directly (so the thread entry
    fn/arg can travel in the channel data region). The main thread's import posts
    a SYS_CLONE request on its channel and blocks for the pump.
  - The pump handles SYS_CLONE on the main channel: it dispatches the clone so
    the kernel allocates the child tid (sys_clone dereferences no guest pointers
    — it only records stack/tls/ctid — so this is a scalar dispatch), carves a
    slot from a reserved thread arena (placed below brk_base so thread channels
    never collide with the guest's mmap/brk region), launches the worker OS
    thread, registers its channel, and returns the tid.
  - spawn_worker_thread instantiates the guest on a fresh OS thread over the
    shared memory, runs the thread prelude (__wasm_init_tls into the slot,
    __stack_pointer = the pthread stack, __wasm_thread_init, __channel_base = the
    slot's channel), and calls the thread entry through the indirect function
    table. host_futex_wake is implemented (pthread setup needs it), which
    required creating the guest memory before the kernel host imports so they can
    reach process memory.
  - The pump binds each channel's own tid before every dispatch and skips a
    channel whose request is already parked, so two channels interleave cleanly.
  - Thread-exit routing (a non-main SYS_EXIT goes to kernel_thread_exit, not the
    process-exit path, keeping the shared pipe alive for the still-blocked
    reader) is implemented as the correct handler for a cleanly-exiting thread.

The fixture native_thread.c: the main thread blocks in read() on an empty pipe
while the writer thread writes to it; the smoke test asserts the read completes
with the writer's bytes. The writer blocks after writing rather than returning,
because a returning pthread runs musl's detached-thread teardown
(__pthread_exit / __unmapself), which needs thread-teardown machinery the
minimal native host does not provide yet; keeping the writer parked focuses the
test on the pump and the cross-thread wakeup, which is B.3's point. Clean
pthread teardown (and thus exercising the thread-exit routing) is future work.

Validation: scripts/dev-shell.sh cargo test -p host-native
--target aarch64-apple-darwin -> 10 passed (run repeatedly; no flakiness).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4 (A). Removes the host-side poll-conversion bypass in handleEpollPwait
and routes epoll_pwait through kernel_handle_channel -> sys_epoll_pwait, so the
kernel — not a host reimplementation — computes epoll readiness. The kernel now
owns epoll readiness semantics; the host owns only the wait/retry loop.

## Why

The bypass existed solely because kernel_handle_channel for epoll_pwait
historically crashed Chrome's V8 (a shared-memory Wasm bug). That crash is now
confirmed GONE: it was documented main-thread-only, and the kernel has since
moved to a dedicated worker. Browser evidence (the docs require it before
removing this workaround): a probe dispatching epoll_pwait through the kernel on
every call fired 10x during a WordPress boot in headless Chromium with zero
crashes (Playwright, passed); Node showed no crash.

## What changed

handleEpollPwait dispatches SYS_EPOLL_PWAIT (timeout 0, a non-blocking readiness
check — this host still owns the wait/retry loop) and copies the kernel-written
epoll_events back to the caller, instead of converting the interest list to a
poll and dispatching SYS_POLL. Everything before (arg parsing, interest lookup,
empty-interest handling) and after (timeout checks, the setTimeout retry with
resolveEpollReadinessIndices) is unchanged. epoll_create, epoll_ctl, and the
epollInterests field are untouched — the mirror is retained, but only to resolve
targeted wake indices for the retry loop (no longer to reimplement readiness).

## Validation

- Chromium (Playwright): WordPress boots and serves pages through the kernel-
  routed epoll; no crash.
- Performance: at parity with the bypass. WordPress front-page boot, 3 runs
  each: bypass ~24.0s (23.9/24.1/24.1), kernel route ~24.5s (24.3/24.7/24.7) —
  within noise. (An earlier apparent 6x regression was an artifact of a
  console.error probe in the epoll hot path, not the change.)
- Typecheck: `npm run typecheck` (tsup --dts-only) clean.

Follow-up (not required to remove the bypass): fully deleting the epollInterests
mirror needs the kernel to expose epoll wake indices to the host, so targeted
wakeups survive without a host-side interest cache.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why

Rust-first migration Phase 5 moves filesystem authority out of the
TypeScript host and into the portable Rust kernel core, so a single
kernel image serves the same FS behavior on Node, browser, and the
native reference host, and so the host's role shrinks to raw byte-leaf
providers (fetch, OPFS, Node fs). Path resolution, symlink walking,
mount crossing, access checks, and the synthetic namespaces (procfs,
devfs, fifo, pty, /etc/mtab) already live in Rust; the TypeScript VFS is
now only a flat, canonical-path backing store behind ~30 host_* imports.

The scratch mounts (/tmp, /var/tmp, /var/log, /var/run, /home/maker,
/root, /srv) start empty, so they are the smallest load-bearing slice to
migrate first: a pure in-memory inode tree with no tar/zip/image parser
and no fetch. This is the foundation the later increments (image-backed
rootfs, then parsers + lazy-blob wake) build on.

## What changed

- New `runtime-core::tmpfs`: an in-kernel inode tree (regular files and
  directories) backing the scratch mounts, generalizing the existing
  negative-handle synthetic-regular pattern. Open files/dirs are named by
  handles in disjoint negative ranges; the read/write cursor stays in the
  per-OFD offset field, so tmpfs is not a shared-cursor backing. Supports
  open/creat/read/write/lseek/fstat/lstat/mkdir/rmdir/unlink/opendir/
  readdir with correct POSIX semantics, including unlink-while-open
  persistence, zero-fill on sparse write, nlink accounting, and a distinct
  st_dev per mount (for future EXDEV/identity).
- `docs/plans/2026-08-28-phase5-vfs-to-rust.md`: the Phase 5 architecture
  (a runtime-core FS choke layer, not a WasmHostIO change) and the
  increment breakdown 1a-1d → image rootfs → parsers.

This module is not yet wired into the syscall dispatch; that is the next
increment (route the scratch-prefix path ops and tmpfs-handle I/O sites
through it, then delete the host-side scratch mounts). It changes no
observable behavior on its own.

## Validation

12 module unit tests (host target) covering the semantics above, all
passing; runtime-core builds clean for wasm32 via the dev shell. Not yet
exercised end-to-end through the kernel — deferred to the wiring
increment, which will add a recording-host cargo test (host never called
for a /tmp path) plus WordPress Chromium boot at cutover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rement 1b)

## Why

Phase 5 of the rust-first migration moves filesystem authority for the scratch
mounts (/tmp, /var/tmp, /var/log, /var/run, /home/maker, /root, /srv) out of the
TypeScript host and into the Rust kernel core. Increment 1a added the in-kernel
tmpfs store; this increment routes the real syscall dispatch through it so a
scratch-mount file is created, read, written, stat'd, and unlinked entirely in
Rust, with the host never consulted for those prefixes.

The wiring stays behind a master enable flag that defaults OFF, so it is fully
dormant: real hosts keep serving the scratch mounts from their own backends, and
the existing test corpus (which uses these paths as ordinary mock-host paths) is
unaffected. The flag is the cutover switch a later increment flips once the full
path-op surface is wired and validated in the browser.

## What changed

- Interception lives in the runtime-core syscall path (not the WasmHostIO host
  adapter) so it is unit-testable with a recording mock host:
  - `fs_stat`/`fs_lstat` helpers route the ~23 direct host stat/lstat sites;
    `namespace_lstat_raw` gains a tmpfs arm.
  - `open`/`openat` build a tmpfs-backed regular-file OFD via a shared
    `open_scratch_tmpfs` helper (FileId::Host from the stable tmpfs dev/ino).
  - Handle I/O arms in read/pread/write/pwrite/lseek/fstat; the write arm passes
    an explicit start offset so O_APPEND never fstats the tmpfs handle against
    the host, while RLIMIT_FSIZE clipping still applies.
  - Four descriptor_backing lifecycle arms mirror the synthetic-regular pattern,
    so fork/dup/exec/close refcount open_count correctly (unlink-while-open
    works); sys_close routes tmpfs handles to release_for_ofd, not host_close.
  - Early tmpfs arms in mkdir/mkdirat, rmdir, unlink.
- Made the negative-handle classes disjoint: the synthetic-regular range was
  `<= -1e9` (unbounded) and shadowed tmpfs handles, causing EBADF; it is now
  bounded to (-2e9, -1e9], with tmpfs file/dir below it.
- `crate::tmpfs::set_enabled` master flag; the syscall dispatch gates on
  `claims_path` (enabled AND scratch-prefix). `owns_path` stays a pure predicate
  for unit tests. Enabling tmpfs unconditionally had regressed ~114 unit tests
  that use scratch paths as generic mock-host paths.

Deferred before the real-host cutover: directory OFDs (open/getdents on a tmpfs
directory currently returns EISDIR), symlinks, rename/link/chmod/chown/access/
statfs/utimensat/ftruncate on tmpfs, AF_UNIX socket and FIFO names under a
scratch prefix, per-inode permission enforcement, and st_dev-based EXDEV.

## Validation

Full runtime-core suite on the host target: 1601 passed, 0 failed (the +13 are
the tmpfs unit tests plus the recording-host wiring test, which drives the real
syscall path with tmpfs enabled and asserts the host is never asked about a
scratch path). runtime-core builds clean for wasm32 via the dev shell. Not yet
exercised on a real host (tmpfs is dormant) — browser/Node validation is gated
on the cutover increment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… 1c)

## Why

Continues the Phase 5 tmpfs wiring toward the real-host cutover. Programs that
use scratch mounts routinely resize files there (log rotation, database journal
preallocation, `> file` truncation), so ftruncate/truncate must operate on the
in-kernel tmpfs rather than falling through to the host.

## What changed

- `crate::tmpfs::truncate_handle` resizes a tmpfs regular file's Rust-backed
  content, zero-filling growth.
- `sys_ftruncate` gains a tmpfs arm: it enforces the access mode and
  RLIMIT_FSIZE exactly as for host and memfd files, takes the current size from
  tmpfs (so the tmpfs handle is never fstat'd against the host), and truncates
  in Rust. Because `sys_truncate(path)` is `open(O_WRONLY)+ftruncate+close` and
  `sys_fallocate` extends via `ftruncate`, both now work on tmpfs for free.

Still gated by the dormant enable flag; inert for non-tmpfs handles.

## Validation

tmpfs unit + wiring tests pass (14), including a new `truncate_grows_and_shrinks`
unit test and a `truncate(path)` assertion driven through the real syscall path.
The full runtime-core suite was green at increment 1b; this change only adds a
tmpfs-handle-guarded branch, inert for every existing (non-tmpfs) descriptor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(Phase 5)

## Why

Programs constantly enumerate the scratch mounts — `ls /tmp`, a shell globbing
`/var/run/*.pid`, a server scanning its spool dir. For the in-kernel tmpfs to be
usable by real software at cutover, opening a scratch-mount directory and reading
it with getdents must work entirely in Rust, not fall through to the host.

## What changed

Modeled on the existing in-kernel devfs directory pattern (a sentinel dir handle,
entries regenerated per call):

- `crate::tmpfs::TMPFS_DIR_SENTINEL` (-170; disjoint from procfs -150 / devfs
  -160), `tmpfs::is_dir`, and `tmpfs::getdents64`, which builds the entry list
  from the live store and hands it to `procfs::write_virtual_dirents64` (the
  shared formatter that injects `.`/`..` and honors the cookie/short-buffer
  protocol).
- `sys_open`/`sys_openat`: a tmpfs directory path now opens as a
  `FileType::Directory` OFD via `open_scratch_tmpfs_dir` (a directory cannot be
  opened for writing → EISDIR), instead of the regular-file path.
- The sentinel is threaded through every directory special-case site, mirroring
  devfs: `sys_getdents64` (new tmpfs branch), the lseek
  kernel-generated-directory branch (seekdir/rewinddir keep the sentinel and use
  the cookie directly), `sys_fstat` (returns the tmpfs directory stat), the close
  "nothing to clean up" branch, and the negative-handle directory-backing
  validity check.

Still gated by the dormant enable flag; every change is an added sentinel case,
inert for existing (host/procfs/devfs) descriptors.

## Validation

Full runtime-core suite on the host target: 1602 passed, 0 failed. The tmpfs
wiring test now creates a file inside a tmpfs directory, opens the directory with
O_DIRECTORY, and reads it back with getdents64, asserting `.`, `..`, and the
child are all present and served by tmpfs (the host is never consulted for the
scratch prefix). runtime-core builds clean for wasm32 via the dev shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why

Symbolic links are pervasive on the scratch mounts: build tools and package
managers create them under /tmp, services publish current-version symlinks under
/var/run, and shells resolve them constantly. For the in-kernel tmpfs to host
real software at cutover, creating, reading, and — most importantly —
*resolving* symlinks on a scratch mount must happen in Rust, and a tmpfs symlink
whose target crosses back into (or out of) the mount must resolve correctly.

## What changed

- `InodeKind::Symlink(target)` stores the link's target bytes; `Inode::stat`
  reports `S_IFLNK` with size = target length; getdents reports `DT_LNK`. The
  non-directory match arms (walk/resolve/rmdir/opendir → ENOTDIR, size/truncate
  → EISDIR) were generalized to cover the new variant.
- `crate::tmpfs::symlink` and `crate::tmpfs::readlink`.
- Kernel wiring: arms in `sys_symlink`/`sys_symlinkat` (create) and
  `sys_readlink`/`sys_readlinkat` (read), and — the load-bearing one —
  `namespace_readlink_raw`, so the kernel's component-by-component path resolver
  follows tmpfs symlinks during every lookup, including relative targets that
  resolve back into the same mount.

Still gated by the dormant enable flag.

## Validation

Full runtime-core suite on the host target: 1603 passed, 0 failed. A new
`symlink_create_and_readlink` unit test covers create/readlink/EINVAL/ENOENT/
EEXIST; the tmpfs wiring test now creates a relative symlink under a scratch
mount, lstats it (S_IFLNK), reads it back, and stats it *through the link* to the
target file — all served by tmpfs with the host never consulted. Builds clean
for wasm32 via the dev shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why

Software on the scratch mounts sets permissions and ownership on the files it
creates there — session files locked down to 0600, spool directories chowned to
a service account, pid files made group-readable. For the in-kernel tmpfs to
host that software at cutover, chmod/chown must update the in-kernel inode and be
reflected by stat, rather than fall through to the host.

## What changed

- `tmpfs::chmod`/`chown` (path) and `fchmod`/`fchown` (open handle) update the
  inode's mode/uid/gid; `chown`/`fchown` honor the `u32::MAX` "unchanged"
  sentinel.
- Arms in every chmod/chown entry point: `sys_chmod`, `sys_fchmodat`,
  `sys_fchmod`; `sys_chown`, `sys_lchown`, `sys_fchownat`, `sys_fchown`. The
  fd-based paths handle both a tmpfs file handle and the directory sentinel.

The existing permission logic (`check_owner_or_root`, and `prepare_chown_ids`'s
`_POSIX_CHOWN_RESTRICTED` enforcement) runs against the tmpfs stat, so ownership
rules are enforced unchanged. Still gated by the dormant enable flag.

Note: open does not yet *enforce* mode bits on access (per-inode permission
enforcement is a separate deferred item); chmod correctly stores and reports the
bits.

## Validation

Full runtime-core suite on the host target: 1604 passed, 0 failed. A new
`chmod_chown_update_metadata` unit test covers path and fd variants plus the
unchanged-field sentinel; the tmpfs wiring test now chmods and chowns a
scratch-mount file and confirms stat reflects both. Builds clean for wasm32 via
the dev shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why

Renaming within the scratch mounts is one of the most load-bearing filesystem
operations in real software: the write-temp-then-rename atomic-commit pattern
(write /tmp/x.tmp, fsync, rename to /tmp/x) is how editors, package managers,
databases, and config writers avoid torn files. For the in-kernel tmpfs to host
that software at cutover, rename on a scratch mount must be an atomic in-kernel
move, and a rename that crosses the tmpfs/host boundary must report EXDEV so
callers fall back to copy+unlink.

## What changed

- `tmpfs::rename` performs a same-mount move with full POSIX replace semantics:
  atomic replacement of a compatible existing destination; ENOTDIR/EISDIR on a
  file/directory type mismatch; ENOTEMPTY when the destination is a non-empty
  directory; EINVAL when moving a directory into its own subtree; a no-op when
  source and destination name the same inode. Parent directory link counts are
  recomputed from scratch (2 + subdirectory count) so any move/replace keeps
  st_nlink correct.
- `sys_rename`/`sys_renameat` route by tmpfs authority: both endpoints on tmpfs
  → in-kernel rename; a tmpfs/host mix, or a cross-scratch-mount move (distinct
  st_dev), → EXDEV.

Still gated by the dormant enable flag.

## Validation

Full runtime-core suite on the host target: 1605 passed, 0 failed. A new
`rename_moves_replaces_and_guards` unit test exercises move, replace,
cross-mount EXDEV, the subtree-cycle guard, ENOTEMPTY, and both type-mismatch
errors; the tmpfs wiring test now performs a write-temp-then-rename atomic commit
under a scratch mount and confirms the temp name is gone and the target holds the
committed bytes. Builds clean for wasm32 via the dev shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ase 5)

## Why

Programs consult statfs on the scratch mounts before writing (free-space
preconditions, filesystem-type sniffing such as "is this tmpfs?"), and check
access() to decide whether an operation will be permitted. For the in-kernel
tmpfs to host that software at cutover, statfs on a scratch path must describe
the in-kernel filesystem rather than the host's, and access() must reflect the
tmpfs file's mode/owner.

## What changed

- `tmpfs::statfs` reports a memory-backed, nosuid filesystem (TMPFS_MAGIC, 4 KiB
  blocks, generous nominal free space — the store grows within kernel Wasm
  memory, not a fixed reservation). `sys_statfs` and `sys_fstatfs` route tmpfs
  paths/handles to it.
- access() needed no change: `sys_access` already computes its result from the
  resolved stat (which is tmpfs-aware) via `check_access_for_ids` with no host
  call, so a tmpfs file's mode/uid/gid are enforced correctly. This also
  confirms the permission-enforcement gap is limited to `open` alone (which is
  still permissive); a later increment closes that.

Still gated by the dormant enable flag.

## Validation

Full runtime-core suite on the host target: 1605 passed, 0 failed. The tmpfs
wiring test now statfs's a scratch path (asserting TMPFS_MAGIC) and access()es a
tmpfs file. Builds clean for wasm32 via the dev shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why

Until now the tmpfs open path skipped permission checks, so a scratch-mount file
could be opened regardless of its mode or owner — inconsistent with access(),
which already enforced them, and unsafe for multi-user scenarios where a service
drops privileges and must not read another user's 0600 session file under /tmp.

## What changed

The tmpfs `open`/`openat` arm now calls `check_open_permissions` before serving
the open, exactly as the host path does. This enforces the directory search
path, the file's access mode, and parent-directory writability for creation. It
is host-free for tmpfs paths: `check_open_permissions` resolves through
`fs_stat`/`check_search_path`, which are tmpfs-aware, so only the host-owned
root `/` is ever queried — never a scratch path. This closes the last
permission-enforcement gap (access/stat/chmod/chown already enforced).

Still gated by the dormant enable flag.

## Validation

Full runtime-core suite on the host target: 1606 passed, 0 failed. A new
`tmpfs_open_enforces_permissions` test creates a 0600 file owned by uid 1000 and
confirms an unrelated unprivileged user is denied both read and write while the
owner is allowed read/write. Builds clean for wasm32 via the dev shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why

Point 1 of the pre-cutover list. Real software on the scratch mounts depends on
file timestamps: `touch`/build tools set mtimes explicitly, incremental builds
compare mtimes, and `ls -l` shows them. Until now tmpfs inodes reported epoch-0
for all three times, so those workflows saw wrong metadata.

## What changed

- The tmpfs inode now carries atime/mtime/ctime; `Inode::stat` reports them.
  `Inode::new` stamps all three at creation; `touch_modified` (write/truncate)
  bumps mtime+ctime; `touch_changed` (chmod/chown) bumps ctime.
- The tmpfs core stays host-free: the syscall layer publishes the host
  CLOCK_REALTIME via `tmpfs_stamp_now` before each mutating tmpfs op, and the
  store stamps from that published value. This is one clock read per mutating
  tmpfs syscall (in-worker, once per call, not per byte) — a deliberate,
  measured cost noted here; if benchmarks show it matters on the write path it
  can be coarsened.
- `sys_utimensat`/`futimens` resolve UTIME_NOW/UTIME_OMIT in-kernel against the
  current times and store the result via `tmpfs::utimensat`.
- Fixed a latent gap found while wiring: `sys_mkdirat` had no tmpfs arm and
  would have created a scratch-mount directory on the host (split-brain).

Still gated by the dormant enable flag.

## Validation

Full runtime-core suite on the host target: 1607 passed, 0 failed. A new
`timestamps_track_create_write_and_utimensat` unit test confirms create-time,
that a write bumps mtime/ctime but not atime, and that utimensat sets explicit
atime/mtime with ctime as the change time. Builds clean for wasm32 via the dev
shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why

Point 2 of the pre-cutover list. Hard links appear on the scratch mounts —
some lock and atomic-publish schemes create a second name for a file, and
link()/linkat() must operate in the kernel rather than fall through to the host
for scratch paths.

## What changed

- `tmpfs::link` creates a second directory entry pointing at an existing file
  inode, bumping nlink and ctime. It refuses hard links to directories (EPERM),
  cross-mount links (EXDEV), and existing destinations (EEXIST); the old path is
  not dereferenced, matching link(2)/linkat(2) without AT_SYMLINK_FOLLOW. The
  existing unlink already decrements nlink and frees only at zero, so
  unlink-of-one-name leaves the content reachable under the other name.
- `sys_link`/`sys_linkat` route by tmpfs authority: both endpoints on tmpfs →
  in-kernel link; a tmpfs/host or cross-scratch-mount mix → EXDEV.

Still gated by the dormant enable flag.

## Validation

Full runtime-core suite on the host target: 1608 passed, 0 failed. A new
`hard_link_shares_inode_and_survives_unlink` unit test confirms the two names
share one inode (equal st_ino, nlink 2), a write via one name is visible via the
other, unlinking one name leaves the data reachable (nlink 1), and the
EXDEV/EPERM/EEXIST guards. Builds clean for wasm32 via the dev shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why

Point 3 of the pre-cutover list, and the part that actually gates WordPress:
php-fpm, nginx, and MariaDB communicate over AF_UNIX sockets bound under the
scratch mounts (e.g. /var/run/php-fpm.sock, /tmp/mysql.sock). Today bind()
creates the socket's filesystem node with a host O_CREAT marker file, so once
the host scratch mounts are removed at cutover, bind() on those paths would fail
and the whole stack would be unable to listen. The socket node must live in the
in-kernel tmpfs.

## What changed

- `InodeKind::Special(type_bits)` — a metadata-only tmpfs node for AF_UNIX
  sockets and (later) FIFOs. It reports S_IFSOCK/S_IFIFO in stat and
  DT_SOCK/DT_FIFO in getdents; opening one as a file is ENXIO.
- `tmpfs::mknod_special` creates such a node (EEXIST if the name is taken).
- `sys_bind` creates a tmpfs S_IFSOCK node for a scratch path (EEXIST →
  EADDRINUSE) instead of a host marker file. The socket endpoint stays in the
  path-keyed `unix_socket` registry, so connect() is unchanged — the registry,
  not the filesystem, is the rendezvous.
- `sys_unlink` on a scratch path drops the registry entry (waking any parked
  datagram senders) before removing the tmpfs node. Closing a bound socket
  already only touched the registry, not the FS node (Linux semantics), so no
  change was needed there.

FIFO nodes on tmpfs are deferred: the fifo table also tracks path-keyed
metadata, so it needs a metadata-ownership decision to avoid double authority —
and FIFOs are not on the WordPress path. Still gated by the dormant enable flag.

## Validation

Full runtime-core suite on the host target: 1610 passed, 0 failed. A tmpfs unit
test covers the special-node semantics (S_IFSOCK stat, ENXIO on open, EEXIST,
unlink), and a syscall-level test binds an AF_UNIX socket to /var/run/wire.sock
with tmpfs enabled and asserts the node is a kernel S_IFSOCK, the registry knows
the path, the host was never asked to create the file, and unlink removes both
the node and the registry entry. Builds clean for wasm32 via the dev shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ring)

## Why

The in-kernel tmpfs has been dormant behind a flag that only tests could set.
Point 4 (cutover) needs the host to turn it on at boot. This lands the enable
mechanism — the switch that makes tmpfs the authority for the scratch mounts —
gated so the default is unchanged until the full cutover.

## What changed

- New kernel export `kernel_set_tmpfs_enabled(i32) -> i32` that toggles
  `runtime_core::tmpfs::set_enabled` and returns the previous state. Additive:
  only the host calls it; existing programs are unaffected, so this is a
  backward-compatible ABI addition (abi/snapshot.json regenerated with the new
  export signature, no ABI_VERSION bump).
- `kernel-worker.ts` calls it at boot (right after ABI validation, before any
  guest filesystem op) via `maybeEnableKernelTmpfs`, gated by
  `WASM_POSIX_TMPFS=1` (Node — for conformance validation) or
  `globalThis.__WASM_POSIX_TMPFS__ = true` (browser). The host handles a kernel
  that lacks the export (older builds) gracefully.

This is the validation/bring-up toggle; the eventual cutover makes it the
default and drops the host-side scratch mounts.

## Validation

The enable path is exercised end-to-end: with WASM_POSIX_TMPFS=1 the kernel
boots through kernel-worker.ts -> maybeEnableKernelTmpfs -> the export with no
crash (the fsync POSIX interface times out identically with tmpfs on and off — a
pre-existing baseline issue, confirming the enable is non-destructive). The
kernel rebuilds with the export present (verified in the resolved kernel.wasm).

Note: a broader conformance-suite oracle (libc-test with tmpfs on) is currently
blocked in this worktree by a pre-existing binary-resolver provisioning failure
that fails all libc functional tests regardless of tmpfs (0/63 with tmpfs off);
that is an environment gap to resolve separately, not a tmpfs defect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why

A spawned process whose working directory cannot be resolved fails with
`setCwd failed for pid N: errno 2`, giving no hint which path was rejected.
During Phase 5 tmpfs conformance validation this made a spawn-test failure
opaque; the message forced a debug session to recover the offending path.

## What changed

`CentralizedKernelWorker.setCwd` now includes the rejected working directory in
the thrown error, e.g. `setCwd failed for pid 100: errno 2
(cwd="/tmp/kandelo-run/work")`. Pure diagnostics; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ariant

## Why

The libc conformance oracle (tmpfs enabled) surfaced that tmpfs claiming a whole
scratch prefix shadows any host submount placed under it. Capturing the design
decision — document the invariant, do NOT teach tmpfs to defer to host submounts
— prevents a future reviewer from "fixing" it in a way that unwinds the
kernel-owned-scratch property (Safari reclaim + split-brain guarantee).

## What changed

Adds a "Cutover invariant" section to the Phase 5 plan: nothing may be mounted
under a scratch prefix while tmpfs owns it; the one oracle deviation
(functional/spawn) is a harness fixture-under-/tmp artifact whose fix belongs in
the harness, not tmpfs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
brandonpayton and others added 30 commits September 4, 2026 19:38
…parked wait; no fork, no kernel change)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native host's channel pump (crates/host-native/src/guest.rs)
hard-coded a single guest process: one SharedMemory, one pid, one
kernel-scratch region, and one ProcessLayout, with run_pump servicing
that one process's channels (main + any pthread worker channels
sharing its memory). posix_spawn (N1-I3a Task 2/3) needs a FRESH
process with its OWN memory running alongside the parent, so the pump
must track a set of processes rather than a single one.

This is a pure refactor: no new behavior, no spawn/exec/waitpid yet.

- Introduce GuestProcess { pid, module, memory, scratch_base, layout,
  channels, next_thread_slot }, one entry per live process.
- run_pump now takes processes: &mut Vec<GuestProcess> and loops over
  every process's live channels, instead of a single guest_mem/pid/
  layout/scratch_ptr quadruple. BlockedOp gained a process_index so a
  parked syscall can be re-dispatched against the right process's
  memory/pid/scratch on a later pump iteration (a channel's byte
  offset alone is not process-unique, since two processes' identically
  computed ProcessLayouts can share the same offset in their own,
  distinct memories).
- Extract launch_process(): the per-process instance-launch sequence
  (scratch allocation, brk/mmap/max-addr, spawning the guest OS
  thread, registering its main channel) that used to be inlined across
  spawn_guest_thread/run_guest. Task 2 will call it again to launch a
  spawned child's process instance. Also extract compute_guest_memory()
  for the layout/SharedMemory computation that must still happen before
  launch_process for the FIRST process, since the kernel's host imports
  (host_futex_wake) are wired to that memory at kernel-instantiation
  time.
- Deliberately kept OUT of launch_process: the kernel-wide rootfs
  overlay/tmpfs/base-image enablement and foreign-prefix registration.
  Those are one-time, kernel-instance-wide toggles (no pid parameter),
  not per-process launch state, so a spawned child must not re-run
  them.

processes always has exactly one entry today (run_guest's only
caller), so this changes no observable behavior: same 15 tests pass
before and after, including the thread/pipe/poll/epoll tests that
exercise the SYS_CLONE worker-thread path this refactor touched most.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 2 of N1-I3a: the native wasmtime host's channel pump now
recognizes SYS_SPAWN (the syscall posix_spawn() issues), decodes the
spawn blob via the kernel's own kernel_spawn_blob_decode, resolves the
program in a new GuestOptions.programs host map, creates the child via
kernel_spawn_process, launches it as a brand-new GuestProcess (Task
1's launch_process — a fresh image, never a fork), publishes the
parent/child edge via kernel_publish_spawn_child, and completes the
parent's posix_spawn call.

Also:
- Defines the kernel_wait4/kernel_execve guest imports (mirroring
  kernel_clone) so a program that calls waitpid()/execve() does not
  trap the build; kernel_wait4 posts SYS_WAIT4 for Task 3 to service,
  kernel_execve returns ENOSYS until image-replacement lands (I3c).
- Fixes host_futex_wake to route through a shared "current process
  memory" cell the pump updates before every kernel_handle_channel
  call, instead of permanently closing over the first process's
  memory — necessary now that a spawned child has its own memory.
- Widens run_pump's termination rule: only processes[0]'s (the boot
  process) exit ends the run, and only once every spawned child has
  also finished all of its channels, so the outcome does not depend on
  a nondeterministic race between the parent and child processes.

Reaping (waitpid) is intentionally not implemented here — Task 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ejection

Fix round 1 (code review of the SYS_SPAWN interception, N1-I3a Task 2):
handle_spawn reported a kernel_publish_spawn_child rejection to the
parent but never removed the already-created child from the kernel's
process table. Per publish_spawn_child's documented contract
(crates/runtime-core/src/process_table.rs), a -ECHILD disposition
specifically means the child's Process record still exists,
unpublished, because the parent disappeared out from under the call --
the host must reclaim it via kernel_remove_process, exactly like the
Node reference host's #rollbackSpawnWithinKernelEntry does on -ECHILD.
-ESRCH (child already absent) and -EINVAL (bad arguments) need no
removal, since there is nothing left to remove.

Does not attempt to tear down the already-launched OS thread/Wasmtime
instance itself -- that remains a documented, open gap the Node
reference host does not solve either.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-I3a)

Task 3 of N1-I3a: the native wasmtime host now services waitpid()
for a posix_spawn'd child. The kernel's own sys_waitpid delegates
child-selection and reaping entirely to the host (its own process
table plays no part), so this defines the env.host_waitpid import
(previously an unresolved trap) as pure host-side bookkeeping:

- A new WaitTable tracks, per run, which pid belongs to which real
  parent and which exited children are still unreaped zombies.
  Populated when the boot process is created, when a spawned child
  is fully published, and when any process's main channel posts
  exit (recording its encoded wait-status word regardless of
  whether a parent is parked on it yet).
- host_waitpid never blocks and never calls back into another
  kernel export (both would risk the single-threaded pump
  deadlocking or aliasing the kernel's own process-table borrows
  mid-dispatch). Instead it returns -EAGAIN when the target child
  exists but hasn't exited, which Wait4 now triggers the existing
  parked-retry mechanism for (mirroring the current blocking
  poll/read table) so the pump keeps servicing the child's channel
  while the parent stays parked.
- Once the child's exit is recorded, the parked retry naturally
  resolves; the pump (never host_waitpid itself) then calls
  kernel_reap_exited_child with the child's REAL parent pid to
  release the kernel's own zombie, and the existing RAW-syscall
  copy-back machinery writes the encoded wait status into the
  caller's buffer.
- wait4 has no fd/OFD target for the kernel's blocked-retry
  registry to pin, and that registry has no entry for it, so this
  syscall skips kernel_blocking_retry_token and uses the same
  token-0 'nothing to pin' convention poll already has.

Also extends the native_spawn_parent fixture to waitpid() its
spawned child and report the decoded WEXITSTATUS, and adds
smoke_spawn_waitpid asserting the full posix_spawn + waitpid path:
child stdout appears and the parent observes WEXITSTATUS == 7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
N1-I3a (native posix_spawn + waitpid) is complete and reviewed: the
native wasmtime host now runs multiple processes — a parent spawns a
child, the child runs in its own GuestProcess, and the parent reaps its
exit status through a parked host_waitpid that never blocks the
single-threaded pump. This matches how the kernel expects any host to
drive waitpid (sys_waitpid delegates the whole contract to host_waitpid),
so it holds Node/browser parity rather than taking a native-only
shortcut.

Records the follow-ups the native path surfaced (the point of N1):
- runtime-core blocked_retry.rs has no wait4 entry, so
  kernel_blocking_retry_token(139) returns -EINVAL; I3a works around it
  host-side (token:0, no lost wakeup). Add wait4 to the reviewed
  host-only-snapshot list so the token query succeeds for all hosts.
- Process-lifecycle: a parent that exits while a child holds a live
  channel currently yields a loud 30s pump timeout instead of POSIX
  orphan-reparent-to-init + immediate parent exit; and an unwaited child
  leaves a zombie. Both fail truthfully today; the fix is the
  reparent-to-init lifecycle piece, best done with the I3b+
  exec-authority work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(N1-I3b)

Task 1 of increment N1-I3b: the native wasmtime host's posix_spawn
handler (handle_spawn) now sources the spawned child's program bytes
from the in-kernel VFS through the kernel's exec-target authority,
instead of the host-side GuestOptions.programs placeholder map that
N1-I3a introduced.

Once kernel_spawn_process returns a child_pid, handle_spawn now:
  1. resolves the spawn path (or, if empty, the decoded argv[0])
     against the CHILD's namespace via kernel_spawn_exec_target_prepare
  2. streams the retained target's full contents out of the kernel via
     the new read_exec_target_bytes helper (kernel_exec_target_size +
     kernel_exec_target_read, chunked through a 64 KiB scratch buffer)
  3. commits the child's initial image via kernel_spawn_exec_commit
  4. launches the child from those bytes exactly as before

GuestOptions.programs and all of its threading through run_guest/
run_pump/handle_spawn is deleted; the VFS is now the sole source of a
spawn child's program bytes. The I3a spawn fixtures/tests are updated
to place the child executable in the BaseImage at /bin/child and spawn
that absolute path.

This task covers only the happy path: a prepare/commit failure does a
best-effort kernel_remove_process + reports the errno. The full
failure/rollback matrix (EACCES nuance, ENOEXEC via a Module::new
catch, explicit kernel_exec_target_cancel use) is N1-I3b Task 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 2 of increment N1-I3b hardens handle_spawn's failure/rollback
matrix so every posix_spawn failure mode reports the correct errno to
the parent and leaks no kernel state (no zombie child process-table
entry, no retained exec target).

- A kernel_spawn_exec_target_prepare failure (case 1) has no target to
  cancel; kernel_remove_process reclaims the child's unpublished
  Process record. Unchanged from Task 1, now covered by tests.
- A read_exec_target_bytes failure, or a Module::new compile failure
  on non-wasm bytes (case 2), retains a target: cancel it via
  kernel_exec_target_cancel before reclaiming the child, then report
  the mapped errno (or ENOEXEC for a bad module, mirroring Node's
  isWasmModuleBytes -> ENOEXEC).
- A kernel_spawn_exec_commit failure (case 3) runs the same
  cancel-then-remove sequence best-effort.
- read_exec_target_bytes no longer anyhow::bail!s on a normal
  kernel-reported failure (it now returns a mapped errno the caller
  turns into fail_spawn); Module::new's error is caught instead of
  ?-propagating into a pump-ending bail!, so bad exec target bytes
  (including a #! script, which I3d shebang support does not
  interpret) cleanly fail ENOEXEC rather than aborting the run.
- Module::new now runs before kernel_spawn_exec_commit (a deliberate
  reorder from Task 1): kernel_spawn_exec_commit consumes the token
  unconditionally, so a compile failure discovered after a successful
  commit would have no target left to cancel.

Adds 3 tests (smoke_spawn_missing_path_enoent, EACCES, ENOEXEC) driven
through native_spawn_parent's new SPAWN_TEST_PATH env-var mode, which
posix_spawns a configurable path and reports its raw posix_spawn
errno instead of waiting on a child. 20/20 host-native tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rnel VFS

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per POSIX the posix_spawn `path` argument is authoritative and an empty
pathname is ENOENT; do not resolve an empty path from argv[0]. Pass the
path through to kernel_spawn_exec_target_prepare, which rejects an empty
path with ENOENT (the correct posix_spawn failure). Drops the argv0
fallback left over from the I3a host-side placeholder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 1 of N1-I3c: intercept SYS_EXECVE in the wasmtime host's channel
pump and drive the kernel's exec-target authority to replace the
calling process's image in place (same pid, fresh address space and
module instance) rather than the ENOSYS the kernel's generic dispatch
returns today.

- Bind kernel_exec_target_prepare/kernel_exec_commit TypedFuncs,
  reusing the owner-generic exec_target_size/read/cancel bindings
  already in place for posix_spawn (N1-I3b).
- Add read_guest_cstring/read_guest_string_array to read execve's
  path/argv/envp straight out of the calling process's own guest
  memory (native wasm32 4-byte pointers), bounded like the Node
  reference host's readStringArrayFromProcess.
- Add the SYS_EXECVE pump branch (handle_execve) mirroring the
  SYS_SPAWN branch's structure: prepare -> read_exec_target_bytes ->
  Module::new -> commit -> compute_guest_memory -> launch_process ->
  swap processes[pi] for the same pid.
- Document, but do not fix, the resulting leak: the exec'ing guest
  thread is parked in a real Wasm memory.atomic.wait32 inside the
  now-superseded module/memory; with no epoch/fuel interruption
  configured on this Engine, it can be neither woken (that would
  resume the doomed pre-exec instance) nor killed, so it and its
  backing SharedMemory are abandoned on every successful execve.
- New fixtures native_exec_parent.c/native_exec_target.c plus
  smoke_execve_replaces_image, proving a successful execve's stdout/
  exit code come from the NEW image and the caller's post-execve code
  never runs.

Scope: host-native only, no kernel/runtime-core/abi/shared changes.
execveat and #! shebang stay out of scope (deferred to I3d); the full
failure/rollback matrix is Task 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ted thread-leak (N1-I3c)

## Why

execve failure handling in the native wasmtime host was incomplete: a
Module::new compile failure on a bad exec target bypassed error reporting
entirely and bailed the whole pump (ending the run), and a commit failure
left a retained kernel exec-target token uncancelled. Both are POSIX gaps:
a failed execve is an ordinary syscall that must return -1/errno to the
caller while the caller's OLD image keeps running, never a fatal host
error and never a leaked kernel-side exec target.

## What changed

- crates/host-native/src/guest.rs: `handle_execve`'s failure matrix now
  covers every case. `Module::new`'s Err is matched explicitly (no more
  `?`-bail) and reported as ENOEXEC to the resumed caller, mirroring
  `handle_spawn`'s existing `child_module` handling. A `kernel_exec_commit`
  failure now cancels the retained target before resuming the caller. Added
  `cancel_exec_target` (execve's cancel-only analog of
  `rollback_exec_target`, since a failed execve never touches the caller's
  own process record) and `terminate_process_after_failed_exec_commit` for
  the one case that truly cannot resume the caller: a
  compute_guest_memory/launch_process failure AFTER kernel_exec_commit
  already committed the new program in the kernel. That case truthfully
  terminates the process (kernel_remove_process + a synthetic fatal
  wait-status) instead of resuming a stale old image the kernel has already
  moved past.
- crates/host-native/fixtures/native_exec_parent.c (+ rebuilt .wasm): the
  execve parent fixture now takes an EXEC_TEST_PATH env var so the same
  binary drives both the happy path and the failure matrix, printing
  "execve errno=<N>" and surviving via _exit(0) when execve returns.
- crates/host-native/src/lib.rs: three new tests
  (smoke_execve_missing_enoent, smoke_execve_non_executable_eacces,
  smoke_execve_not_wasm_enoexec) assert the caller survives with the
  correct errno for ENOENT/EACCES/ENOEXEC. Full suite: 24/24.

## Validation

cargo test -p host-native --target aarch64-apple-darwin: 24/24 passed
(13.7s, no test near the 30s pump cap). RED confirmed by temporarily
reverting guest.rs to Task 1's state: smoke_execve_not_wasm_enoexec failed
with the pump-ending Module::new bail; the ENOENT/EACCES cases already
passed under Task 1's baseline prepare<0 handling. fmt/clippy diffs are
pre-existing toolchain drift unrelated to this change (verified via git
stash), per repo convention; not treated as a gate.
Records I3c (native execve replaces a running process's image in place
via the kernel's non-spawn exec-target authority; POSIX success/failure
asymmetry) as done, and the parked follow-ups: a NULL/OOB pathname
surfaces ENOENT rather than EFAULT (degenerate input), and
multi-threaded execve is deferred. Elevates the native thread-reclamation
gap (a parked guest thread cannot be reclaimed without a wasmtime Engine
epoch-interruption change) to the headline item for the I4 checkpoint,
since fork faces the identical thread-lifecycle problem.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t path (N1-I3d)

Refactors N1-I3c's handle_execve into handle_exec_common, shared by both
execve's SYS_EXECVE and the new SYS_EXECVEAT (386) branch, which reads the
guest's real dirfd/path/argv/envp/flags wire args and passes dirfd+flags
straight through to kernel_exec_target_prepare. execve now calls the same
helper with AT_FDCWD/flags=0. The full prepare -> read -> compile -> commit
-> swap flow and its success/failure asymmetry are unchanged, just shared.

fail_execve is renamed fail_exec and now takes the actual syscall_nr so a
failed execveat completes its channel under SYS_EXECVEAT rather than always
SYS_EXECVE.

Adds native_execveat.c (execveat(AT_FDCWD, "/bin/exectarget", ...) via the
raw syscall() wrapper, since musl has no plain execveat() symbol) and
smoke_execveat_replaces_image, reusing I3c's /bin/exectarget image.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…esolve_shebang) (N1-I3d)

Why: the kernel already decoded a #! interpreter line
(exec_target::shebang), but walking the chain end-to-end (prepare the
interpreter, enforce the one-level nesting limit, assemble the argv
prefix) still lived in the host (a discarded host-only draft) or in
Node TypeScript. That is backwards for the rust-first campaign: this
logic belongs in the kernel so a native host only needs to
byte-fetch/instantiate/commit/launch whatever token resolution
produces.

What changed:
- crates/shared/src/lib.rs: added the missing Errno::ENOEXEC = 8
  variant (POSIX/Linux value 8; every host-side callsite already
  hardcoded this value, but the shared Rust Errno enum lacked it).
- crates/runtime-core/src/exec_target.rs: added
  PreparedExecTarget::diagnostic_path(), and resolve_shebang() plus
  its ShebangArgvPrefix/ResolvedShebang result types. A non-script
  target passes through unchanged; a #! script has its own retained
  target canceled (a script's set-ID bits are never honored) and its
  decoded interpreter prepared under the same owner. A nested #! chain
  (the interpreter is itself a script) fails ENOEXEC with the
  half-resolved interpreter token canceled, so no token is ever
  retained on that error path.
- crates/kernel/src/wasm_api.rs: added the
  kernel_exec_target_resolve_shebang(owner_pid, token, out_ptr,
  out_len) -> i64 export, mirroring the validation/serialization shape
  of the existing kernel_exec_target_shebang export. Record format
  (little-endian): [kind: u8][final_token: u32], then, only if kind ==
  1 (script), [has_arg: u8][interp_len: u32][arg_len: u32]
  [script_path_len: u32][interp bytes][arg bytes][script_path bytes].
- abi/snapshot.json: regenerated; the only change is the new
  kernel_exec_target_resolve_shebang export entry. Verified additive
  against the pre-task base commit via
  ABI_CHECK_BASE_REF=f0696f6f2 scripts/check-abi-version.sh, which
  classifies it as an additive-compatible change and does not require
  an ABI_VERSION bump.

Validation:
- New runtime-core unit tests (RED before resolve_shebang existed,
  GREEN after) cover: a script resolving to a different, non-script
  final token with the correct argv-prefix pieces; a nested #! chain
  failing ENOEXEC with an empty prepared-exec-target ledger; and a
  plain binary passing through unchanged.
- cargo test -p runtime-core --target aarch64-apple-darwin: full
  suite green (1754 tests), no regressions.
- cargo test -p host-native --target aarch64-apple-darwin
  smoke_loads_real_kernel_and_reads_abi: still passes against the
  rebuilt kernel.wasm.
- local-binaries/kernel.wasm (gitignored) rebuilt and reinstalled via
  install_local_binary for local verification only; not committed.

Not run: clippy and cargo fmt --check both have pre-existing failures
on this branch unrelated to this change (confirmed via git stash),
per this repo's validation contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…und 1, N1-I3d)

Why: code review on the prior commit (kernel_exec_target_resolve_shebang)
found that resolve_shebang's one-level shebang-nesting check used
`shebang(...)?.is_some()`, so a shebang() read error on the
freshly-prepared interpreter token (e.g. a positioned-read I/O
failure) propagated out of resolve_shebang without canceling that
token first — leaking a retained prepared-exec-target. This violated
the same "zero retained tokens on any error path" invariant the
original commit's tests already checked for the ENOEXEC/nested-chain
path, just not for a read-error on that same freshly-prepared token.

What changed:
- crates/runtime-core/src/exec_target.rs: replaced the
  `?`-propagating check with an explicit match on
  shebang(proc, host, owner_pid, interp_token): Ok(Some(_)) keeps the
  existing cancel-then-ENOEXEC behavior for a nested #! chain; Ok(None)
  is the unchanged success path; Err(error) is new — it best-effort
  cancels interp_token (so a cancel failure cannot mask the original
  read error) and returns the original error.
- crates/runtime-core/src/syscalls.rs: added MockHostIO.pread_error_handle
  (default None, fully backward compatible with every existing test)
  so a test can scope an injected pread failure to one specific host
  handle instead of every host_pread call. Added
  resolve_shebang_releases_the_interpreter_token_when_its_header_read_fails,
  which injects a read failure on exactly the interpreter's handle
  (the script's own earlier header read, on a different handle, still
  succeeds) and asserts the prepared-exec-target ledger is empty
  afterward.

Validation:
- cargo test -p runtime-core --target aarch64-apple-darwin
  resolve_shebang: 4/4 pass (3 previous + 1 new).
- cargo test -p runtime-core --target aarch64-apple-darwin (full
  suite): 1755 passed, 0 failed (no regressions).
- Rebuilt local-binaries/kernel.wasm (gitignored) via
  cargo build --release -p kandelo -Z build-std=core,alloc +
  install_local_binary; the local-generation symlink hash changed,
  confirming a fresh artifact.
- git diff --stat abi/snapshot.json after this fix: empty.
  scripts/check-abi-version.sh confirms 'abi: snapshot is in sync with
  sources.' This fix is internal control-flow only; it does not touch
  the exported function's name, signature, or serialized record
  format, so no ABI change was expected or produced.
- cargo test -p host-native --target aarch64-apple-darwin
  smoke_loads_real_kernel_and_reads_abi: still passes against the
  rebuilt kernel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(N1-I3d)

Wire the native wasmtime host's execve, execveat, and posix_spawn paths to
call the kernel's kernel_exec_target_resolve_shebang export (N1-I3d Task 2,
already committed) right after preparing an exec target and before reading
its bytes or compiling it. The host does no shebang decision logic itself:
it allocates a scratch buffer, calls the export, and decodes the fixed
record it returns ([kind][final_token], plus [has_arg][interp][arg]
[script_path] when kind == 1). All script detection, the one-level nesting
limit, interpreter retargeting, and argv-prefix assembly stay in the
kernel'\''s resolve_shebang.

Adds a small ShebangError enum (ScratchAlloc vs Resolved) so each call site
runs the correct rollback: a Resolved error means the kernel already
released every token it touched (no host cancel), while a ScratchAlloc
error means the host'\''s own allocation failed before the kernel export was
even called, so the previously-retained token still needs the ordinary
target-retained rollback.

Also updates two pre-existing not-wasm-bytes tests whose fixture content
happened to start with a real #! line; now that #! bytes are resolved,
that content would exercise the shebang path instead of the intended
plain-garbage-bytes path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…N1-I3d)

Why: whole-increment review found the mirror image of fix round 1's
leak, this time in the C-ABI export wrapper rather than
exec_target::resolve_shebang itself. kernel_exec_target_resolve_shebang
calls resolve_shebang first, which on success retains exactly one
token (resolved.final_token), and only afterward serializes the
record into the caller's out_ptr buffer. Both post-resolve -EOVERFLOW
guards (the non-script out.len() < 5 check, and the script
total > out.len() check) returned a bare negative errno without
canceling that already-retained token. The native host maps any
negative return from this export to ShebangError::Resolved, whose
contract is "the kernel already released every token on error, the
host must not cancel" -- so on this path the prepared-exec target
(and its OFD lease / open host file handle) would leak permanently in
the calling pid's ledger. This is guest-constructible: a #!
interpreter line near the 4096-byte header cap plus a near-PATH_MAX
script path produces a record that exceeds the host's fixed-size
scratch buffer.

What changed:
- crates/kernel/src/wasm_api.rs: added a best-effort
  crate::exec_target::cancel(proc, advisory_locks, &mut host,
  owner_pid, resolved.final_token) (via `let _ =`, so a cancel failure
  cannot mask the EOVERFLOW being reported) before each of the two
  guards the coordinator named, plus the defensive checked_add
  overflow guard computing the record's total length -- unreachable in
  practice given the interpreter/argument/script-path length bounds,
  but structurally the same "post-resolve return that left a token
  retained" defect, so it gets the same treatment for the invariant to
  hold unconditionally. Did not touch kernel_exec_target_shebang (the
  sibling decode-only export): it never retains a token, so it has
  nothing to leak.

Test coverage: no new unit test in crates/kernel -- every sibling
kernel_exec_target_* export in this file has zero existing unit tests,
because they all read through WasmHostIO (real wasm host-import calls)
and the global PROCESS_TABLE, neither available outside an actual
instantiated kernel. The underlying resolve_shebang zero-leak
invariant is already covered by the runtime-core suite (including
round 1's read-error regression test); this fix is two single-line,
mechanical cancel() calls using the exact argument shape the sibling
kernel_exec_target_cancel export (three functions below) already uses.
Documented in task-2-report.md as the "not cleanly injectable at the
export layer" case.

Validation:
- cargo test -p runtime-core --target aarch64-apple-darwin
  resolve_shebang: 4/4 pass (unchanged -- this round only touched
  crates/kernel/src/wasm_api.rs).
- Rebuilt local-binaries/kernel.wasm (gitignored) via
  cargo build --release -p kandelo -Z build-std=core,alloc +
  install_local_binary; the local-generation symlink hash changed,
  confirming a fresh artifact.
- git diff --stat abi/snapshot.json after this fix: empty.
  scripts/check-abi-version.sh confirms 'abi: snapshot is in sync with
  sources.' This fix only adds cleanup calls on already-existing
  error-return paths; the export's name, signature, and serialized
  record format are unchanged.
- cargo test -p host-native --target aarch64-apple-darwin
  smoke_loads_real_kernel_and_reads_abi: still passes against the
  rebuilt kernel.
- cargo fmt -p kandelo -- --check: zero diff touching this round's
  changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…amation spike

I3d lands execveat and moves #! shebang resolution into the kernel
(kernel_exec_target_resolve_shebang) so the native host holds no shebang
decision logic — the campaign-altitude correction. Records the
thread-reclamation spike: epoch/fuel provably cannot interrupt a parked
atomic.wait32, so native thread reclamation (execve-abandon, fork replay
threads, spawn -ECHILD rollback) needs a cooperative TEARDOWN
channel-status sentinel + a guest-glue trap — the first I4 work item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion (N1-R)

Task 1 of increment N1-R (native thread reclamation). Adds the
constant + guest-glue trap check that let a future host pump
reclaim a guest OS thread parked in the channel's
memory.atomic.wait32, without letting it resume a superseded or
doomed image (execve-abandon, fork-replay teardown, spawn -ECHILD
rollback). No host wiring lands here (Task 2); this change is
inert until a pump writes CH_TEARDOWN.

- crates/shared/src/lib.rs: add ChannelStatus::Teardown = 4 (next
  free channel-status value, verified distinct from Idle/Pending/
  Complete/Error by a new unit test) and teach from_u32 about it.
- tools/xtask/src/dump_abi.rs: wire Teardown into every generator
  surface that mirrors ChannelStatus (the C channel-contract
  header, the TypeScript ABI module, the snapshot channel-status
  list, and the existing header-content regression test).
- libc/glue/abi_constants.h, libc/musl-overlay/*, host/src/generated/abi.ts,
  abi/snapshot.json: regenerated via
  scripts/check-abi-version.sh update. ABI_VERSION stays 44 (this
  branch already carries the epoch-44 bump versus origin/main; the
  snapshot diff is a purely additive channel_status_codes entry).
- libc/glue/channel_syscall.c: immediately after the existing wait
  loop and before reading CH_RETURN/CH_ERRNO, re-read the status
  word via the same get_channel_base()/CH_STATUS idiom the
  surrounding code already uses, and __builtin_trap() if it reads
  CH_TEARDOWN.
- Rebuilt musl (scripts/build-musl.sh) and the native-host guest
  fixtures (crates/host-native/fixtures/build-fixtures.sh) so they
  carry the new glue.

Validation: cargo test -p host-native --target aarch64-apple-darwin
-> 28/28 passing (unchanged from baseline; the new check is never
exercised because no pump writes CH_TEARDOWN yet). cargo test -p
wasm-posix-shared and cargo test -p xtask --bin xtask (dump_abi
module) also pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ack via TEARDOWN (N1-R)

Task 2 of increment N1-R (native thread reclamation), consuming Task
1's CH_TEARDOWN channel-status sentinel (d8981e9). Wires the host
pump to tear down and join a guest OS thread that would otherwise be
permanently abandoned parked in memory.atomic.wait32.

- GuestProcess gains thread_handles: HashMap<usize, JoinHandle<()>>
  keyed by channel offset (previously every spawn_guest_thread/
  spawn_worker_thread JoinHandle was discarded at the call site, so
  nothing could ever be joined).
- reclaim_parked_thread(mem, ch): publishes CH_TEARDOWN (release
  store) into ch's status word and atomic_notify()s it, mirroring
  complete_channel's exact status-word address math.
- join_reclaimed_thread: blocking join after teardown+notify; under
  cfg(test) increments RECLAIMED_THREAD_JOIN_COUNT so a test can
  observe reclamation deterministically.
- reclaim_all_channels(proc_): reclaims every channel of a
  soon-to-be-dropped GuestProcess whose status word currently reads
  PENDING (i.e. is genuinely parked in the channel wait, the same
  invariant channel_syscall.c relies on before calling wait32) and
  joins each; a compute-bound sibling channel that is NOT pending is
  left alone (documented multi-threaded-execve residual - writing
  TEARDOWN there would be clobbered by that thread's own next
  CH_PENDING store, and joining it could hang forever).
- handle_exec_common's execve-success path: replaces the previous
  "documented leak" (old thread abandoned, handle never even kept)
  with mem::replace + reclaim_all_channels on the old GuestProcess,
  covering every parked channel it owned, not just the caller's own.
- handle_spawn's -ECHILD rollback: in addition to the existing
  kernel_remove_process call, pops the just-pushed child (guaranteed
  to be processes.len() - 1) and reclaims its thread the same way.
- smoke_execve_reclaims_thread (host-native/src/lib.rs): loops
  run_guest 5x over the existing I3c execve fixtures and asserts, per
  iteration, both the unchanged functional outcome and a >= 1 delta
  in RECLAIMED_THREAD_JOIN_COUNT. RED verified by temporarily
  reverting the swap-site wiring (delta stayed 0); GREEN restored,
  3x stable reruns.

Host-side only: crates/host-native/src/{guest.rs,lib.rs}. No ABI,
glue, kernel, musl, or fixture changes - fork replay-thread teardown
(I4) is deliberately out of scope and will reuse reclaim_parked_thread.

Validation: cargo test -p host-native --target aarch64-apple-darwin
-> 29/29 passing (28 pre-existing + smoke_execve_reclaims_thread).
cargo build -p host-native is warning-free. cargo clippy fails with a
pre-existing wasmtime/rustc-version mismatch (E0514, reproduced
identically against d8981e9 via git stash); cargo fmt --check has
the same pre-existing diff count as baseline plus two of my own
lines fixed to match, plus a few new lines that copy an
already-widespread pre-existing struct-literal-wrap style elsewhere
in this file - neither touched further per the toolchain-mismatch
guardrail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Whole-increment review found the worker-exit path removed the channel
but left its thread_handles entry, so a thread-churning long-lived
process accumulated one stale dead-thread JoinHandle per pthread ever
created (freed only at process teardown). Drop the entry alongside the
channel. Small, but a leak in a leak-elimination increment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…) (N1-I4)

N1-I4 Task 1: instantiate the shared co-resident fork-module
(crates/fork-module, a PIC wasm side module built to
local-binaries/fork_module32.wasm) in the native wasmtime host, sharing
the guest's SharedMemory as env.memory. Frames-only: the reference/
exception import surface (wpk_fork_host.* + env.resolve_externref) is
stubbed as inert traps this path never calls. No SYS_FORK/kernel wiring
and no capture/replay driven here (Task 2/3); no crates/kernel,
runtime-core, shared, fork-module, or ABI change.

- lib.rs: kernel_engine() now enables wasm_gc (Wasmtime 35 gates all
  funcref/externref/anyref parsing under the GC proposal's heap-types
  machinery, not just the reference-types proposal; without this
  Module::new cannot even parse the fork-module). Added
  fork_module_path() mirroring kernel_wasm_path().
- guest.rs: added instantiate_fork_module(), which reads the module's
  dylink.0 mem_info to size its static/BSS/shadow-stack region, places
  that region at the top of the guest's existing max_addr ceiling (the
  same value launch_process already passes to kernel_set_max_addr),
  supplies the placement globals/tables/inert import stubs, and binds
  the fm_* coordinator TypedFuncs into a new ForkModule struct.
- guest.rs: new smoke_instantiates_fork_module test proves real PIC
  side-module instantiation against a real guest layout, plus one
  benign coordinator call (fm_set_format + fm_last_errno == 0).

Full suite: 30/30 passing (29 pre-existing + 1 new), run via
scripts/dev-shell.sh cargo test -p host-native --target
aarch64-apple-darwin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent child module (N1-I4)

Task 2 of N1-I4: the guest's kernel.kernel_fork(mode) direct import now
drives a real child process on the native Wasmtime host. handle_fork
calls kernel_fork_process for kernel-side identity, clone_guest_memory
makes a PRIVATE byte-for-byte copy of the parent's guest memory (never
shared, unlike I3a's thread clone), and launch_process creates the
child's guest Instance over that copy under the child pid — reusing the
same helper the boot path and posix_spawn already use.

A new GuestOptions.enable_fork_module flag (default false, so every
pre-existing test is unaffected) instantiates a co-resident fork-module
(Task 1's instantiate_fork_module) for both the parent and the child and
wires the guest's five __wpk_fork_frame_*/__wpk_fork_resume_peek imports
to its exports in the SAME Store. launch_process now also shrinks the
kernel's own max_addr ceiling to the fork-module's memory_base (Task 1's
concern 3) whenever a fork-module is co-resident, for every process a run
launches — confirmed by inspection that both the parent and the child get
the identical shrink below the full 1 GiB ceiling.

This task does not drive the fm_* capture/replay coordinator (Task 3): a
fork child never actually executes any of its copied program (that would
either replay a fork bomb — the same program's main() would call fork()
again — or hit the CRT's unrelated kernel_is_fork_child/fork_child_exec
bootstrap, which this host does not wire up). Instead the child's guest
thread instantiates everything (proving no trap) and then posts an
already-successful SYS_EXIT_GROUP(0) on its own channel, reusing the
existing process-exit path to reap it cleanly.

Building this surfaced a real bug: cloning the parent's memory also
copies its live SYS_FORK request (still STATUS_PENDING) on the channel;
without clearing it, the pump could reinterpret that stale copied request
as a fresh one from the child and recursively fork it, observed as a
158-process runaway before the 30s pump timeout. Fixed by zeroing the
child's copied channel header before launch_process spawns its thread —
the same defensive zero the Node reference host already does in
handleOrdinaryFork.

New fixture native_fork.c/.wasm + smoke_fork_parent_child prove: the
child process is created with a private memory copy and a co-resident
module, no instantiation trap occurs, and the parent correctly resumes,
waits, and exits 0 (reaping the child's synthetic status). All 30
pre-existing host-native tests remain green; full suite is 31/31.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wasmtime 35 cannot load a fork-instrumented guest: it rejects the
exception-handling proposal's exnref/Exn heap type outright
("unsupported heap type Exn", wasmtime-environ-35/src/types.rs:2263-2267),
which wasm-fork-instrument's frame-unwind/journal machinery emits. This
blocks native fork (N1-I4/I5). Wasmtime 48 supports the exceptions
proposal.

- crates/host-native/Cargo.toml: wasmtime 35 -> 48 (resolves to the
  latest stable 48.0.1, not the 49.x rc).
- Reconciled the one real API break this jump caused in host-native:
  wasmtime::Error is no longer a bare alias for anyhow::Error in 48, so
  a Linker::func_wrap closure and a match-arm Err construction that
  crossed that boundary needed explicit conversions
  (guest.rs kernel_exit closure, is_unreachable_trap). Every other
  Wasmtime API this crate uses (Config/Store/Linker/Module/Instance/
  Func/TypedFunc/Global/Table/Memory/SharedMemory/Val/Ref) kept
  identical signatures across the jump.
- kernel_engine() (lib.rs) now explicitly enables
  Config::wasm_exceptions(true) (the 48 method for the
  exception-handling proposal) alongside the existing wasm_gc(true),
  and Config::shared_memory(true) -- a new, off-by-default knob 48
  requires in addition to wasm_threads(true) to construct a
  SharedMemory at all (upstream now documents wasm threads/shared
  memory as a tier-2 feature with no security-update guarantee; see
  the report for why this matters for later fork/reclamation work).
- Added smoke_loads_fork_instrumented_guest: builds a minimal
  fork-using fixture through the real production instrumentation
  pipeline (scripts/build-fork-instrumented-test-fixture.sh ->
  scripts/run-wasm-fork-instrument.sh, the same tool every fork-using
  package build runs through) and asserts wasmtime::Module::new
  succeeds on the wasmtime-48 engine -- direct proof the exnref/Exn
  blocker is resolved. A negative control (wasm_exceptions(false))
  reproduces the equivalent "exception refs not supported" failure,
  confirming the fix is load-bearing.

31/31 pre-existing host-native tests + the new acceptance test all
green (32/32). No kernel/abi/shared/glue change; ABI_VERSION and
abi/snapshot.json are unaffected.

Full API-churn/reconciliation detail:
.superpowers/sdd/2026-09-05-n1-i4-native-fork-frames/wasmtime-upgrade-report.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fresh authoritative remaining-work plan superseding the layered
2026-09-04 doc: purpose/Bar, the 2026-09-05 decisions (Tier-2 wasmtime
acceptable; reference bar = all kinds no EOPNOTSUPP; batch validation
once at completion; close the fork/exec residuals incl. real vfork;
curation boundary = self-contained testable units), Part A (done), Part
B (remaining: F fork line, N native+ABI, H host-surface migrations, Z
freeze), and an explicit M0-(you-are-here)->M-SHIP milestone map with a
completion gate per step. Also records the §8.6 decisions + the
vfork-audit-first directive in the superseded doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ument table-elem gap (N1-I4)

Implements the full N1-I4 Task 3 native fork coordinator drive against a
genuinely wasm-fork-instrument'ed guest (native_fork.instrumented.wasm,
built through the real scripts/run-wasm-fork-instrument.sh):

- kernel_fork's Idle/Replaying phases (fm_begin_unwind + the guest's own
  wpk_fork_unwind_begin; wpk_fork_rewind_end + fm_finish_replay)
- run_fork_capable_entry: the guest OS thread's entry loop, alternating
  _start (once) and wpk_fork_resume_start (every fork re-entry), catching
  the escaped env.__wpk_fork_unwind exception as Trap::UnhandledTag
- drive_fork_capture_seal_and_launch_child: unwind_end -> fm_finish_unwind
  -> fm_serialize_journal_alloc/fm_journal_image_len -> the real SYS_FORK
  channel post (smuggling root/image_ptr/image_len like kernel_clone
  smuggles fn_ptr/arg) -> fm_begin_replay -> wpk_fork_rewind_begin
- GuestForkFormat (parses the guest's own KLCF/KFRC custom sections) +
  ForkEntry::ChildReplay, threaded through launch_process/handle_fork
  alongside the existing ChildPendingStub legacy fallback
- ForkProofOfUse: fm_frames_committed/replayed + the four reference-path
  counters, accumulated across every fork-module instance a run
  instantiates, surfaced on RunOutcome for test verification
- The guest's env.__wpk_fork_unwind private tag, wired from the guest's
  own declared TagType; define_unknown_imports_as_default_values for the
  handful of non-function env.__wpk_fork_* imports (tables/globals)
  define_unknown_imports_as_traps cannot cover

BLOCKED before end-to-end verification: the instrumented fixture traps with
'uninitialized element' on the GUEST'S OWN FIRST _start call -- before
fork() is ever reached -- because the guest's real indirect-call function
table (populated with 4 real entries in the un-instrumented build) has zero
populated entries anywhere in the instrumented module's Element section.
Confirmed via a raw Element-section decode (independent of wabt, which
cannot parse this module's GC/exception sections) that none of the 4
segments wasm-fork-instrument emits target the guest's own table; confirmed
this is not a host-side wiring defect (the table is guest-owned/exported,
never touched by this crate's Linker code); confirmed the SDK's real
link flags (sdk/src/lib/flags.ts) are byte-identical to this fixture's on
every relevant point, so this is not fixture-build-recipe drift. This is a
crates/fork-instrument concern, outside this task's crates/host-native-only
scope. Full findings, evidence, and reproduction steps:
.superpowers/sdd/2026-09-05-n1-i4-native-fork-frames/task-3-report-v2.md

Full suite: 31/31 pre-existing tests green (no regression); the two new
tests (smoke_fork_parent_child rewritten for the full claim,
smoke_fork_no_reference_path added) are red for the reason above -- not
faked or weakened to pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…aitpid registration; land N1-I4 Task 3

Amends 43ea3d1, resolving the BLOCKED finding there. The 'uninitialized
element' trap was a crates/host-native instantiation gap, not a
crates/fork-instrument bug (diagnosis:
docs/plans/2026-09-05-fork-instrument-table-elem-diagnosis.md):
wasm-fork-instrument converts a guest's active element/data segments to
passive and defers their initialization into an exported
wpk_fork_module_bootstrap function (never the module's own start). Node and
browser call this export once, right after instantiation and before
_start; this host never did.

Three fixes, found and verified in sequence against a real end-to-end run:

1. Call the guest's own wpk_fork_module_bootstrap (fresh instance) or
   wpk_fork_module_thread_bootstrap (fresh table, memory copy already
   initialized -- the fork-child-replay case) before its entry point, in
   run_fork_capable_entry. A no-op for any non-instrumented guest (neither
   export exists), so all 31 pre-existing tests are unaffected.

2. env.__wpk_fork_resume_table is not fork-module-owned like the 5 frame
   imports -- it is the real cross-activation resume-dispatch table
   wpk_fork_resume_start's call_indirect targets, and needs actual funcrefs
   copied from the guest's own __wpk_fork_resume_catalog export (mirrors
   host/src/fork-replay-events.ts's ForkResumeTable: slot 0 reserved as a
   sentinel, slot i+1 for the i-th KFRC record). Built for real instead of
   leaving it at define_unknown_imports_as_default_values' bare minimum.

3. handle_fork never registered the fork child as waitable
   (wait_table.parent_of), unlike handle_spawn -- a pre-existing gap no
   test had reached before. host_waitpid returned -ECHILD immediately, and
   the fixture's WEXITSTATUS(st) read uninitialized (zero) stack memory,
   producing exit_code 0 instead of the child's real 3. Fixed by adding the
   same parent_of.insert(child_pid, parent_pid) handle_spawn already does.

Also fixed the fork-unwind-escape detector: Wasmtime 48's actual shape for
an uncaught exception escaping a call is wasmtime::ThrownException (a
GC-store-rooted pending-exception slot), not Trap::UnhandledTag (reserved
for the unrelated stack-switching proposal) -- invisible in the prior pass
since execution never got far enough to throw at all.

Extended native_fork.c with a volatile int marker live across the fork()
boundary (feeding the exit code) to prove frame preservation independent of
p, which never needs preservation since it is only assigned from fork()'s
own return. Empirically, fm_frames_committed/fm_frames_replayed stay 0 for
this fixture even with a genuinely live local -- wasm-fork-instrument's
switch-dispatch resume reconstructs state via call_indirect against the
resume table/catalog rather than an explicit frame push/pull, so those two
counters appear to measure a legacy/alternate path this simple, non-dlopen
fork never exercises. Replaced that assertion with the marker-dependent
exit code (3, not 9) as the load-bearing frame-preservation proof; the four
reference-path counters stay asserted == 0 unchanged.

Investigated Task-2 concern B (unguarded launch_process failure after
kernel_fork_process succeeds): confirmed handle_spawn has the identical,
already-documented pattern -- not fork-specific, not a cheap fix without
touching both paths symmetrically. Left as a forward note.

Full suite: 33/33 green (31 pre-existing + smoke_fork_parent_child +
smoke_fork_no_reference_path, all passing -- no test skipped, weakened
without justification, or faked). Full findings, root-cause evidence, and
forward concerns: .superpowers/sdd/2026-09-05-n1-i4-native-fork-frames/task-3-report-v2.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tstrap diagnosis

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant