Kernel: rust-first migration — unified ABI-44 tree (transport, native host, VFS, fork/exec) - #1350
Draft
brandonpayton wants to merge 260 commits into
Draft
Kernel: rust-first migration — unified ABI-44 tree (transport, native host, VFS, fork/exec)#1350brandonpayton wants to merge 260 commits into
brandonpayton wants to merge 260 commits into
Conversation
…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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 separatebranches:
guest syscall ABI for dispatch; it consumes a generated contract and
routes to
kernel_handle_channel.crates/host-native): the samekernel.wasmcan run outside Node/browser, proving the boundary is notsecretly JavaScript-shaped.
is computed by the kernel; the host keeps only the wait/retry loop and the
Atomics.waitprimitive.unconditional
/authority (path resolution, metadata, permissions, COW);the host serves only immutable bytes via
blob_read/fetch_archive.fork-codeccrate + a co-residentRust fork-module drive fork continuation; exec/spawn authority moved into
the kernel; reference kinds no package uses fail loud with
EOPNOTSUPPrather than silently falling back.
ABI_VERSIONis44. It is still in development —mainis43, so 44 isunreleased; 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 byrebasing 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
mainhad independently landed the officialversion 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:
the abort/rollback path is now driven by the
fork-codec/fork-moduleRust with a minimal host seam, closing the last supported-path fork logic
that still lived in TypeScript.
crates/host-native), the campaign'sforcing function: it boots the real
kernel.wasmon wasmtime with the samekernel exports and host imports as Node and the browser, so any reducible
host logic shows up as a divergence.
defaults to a virtual in-memory
/(in-kernel overlay + tmpfs), neverthe real host FS; real directories are reachable only via an explicit
mount, at parity with the Node host's
HostFileSystem.content at
/via an RTFS manifest plus ahost_blob_read-backed blobmap (no heavy
rootfs.vfsbuild), so a guest reads shipped base files.posix_spawn+waitpid. The pump now runs Nprocesses: a parent spawns a child, the child runs in its own process,
and the parent reaps its exit status through a parked
host_waitpidthatnever 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-nativetests.As intended, the native path is already surfacing platform follow-ups:
runtime-core's blocked-retry table has nowait4entry (worked aroundhost-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.
from source. A
HOST_RAW_SYSCALLSaudit confirms the opaque-recordfast-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).
host-nativetest suite runs green (17/17).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.