diff --git a/docs/fork-instrumentation.md b/docs/fork-instrumentation.md index 8b68e380e..3473837a3 100644 --- a/docs/fork-instrumentation.md +++ b/docs/fork-instrumentation.md @@ -188,6 +188,15 @@ time and 0 in modules that do not contain plain-catch capture sites. internal `Runtime` struct, and `wpk_fork_unwind_begin` writes `buf + frames_start_offset` into `*(buf + 0)` on every invocation. +After an unwind, the host compares this absolute `current_pos` with +`buf + FORK_SAVE_BUFFER_SIZE` before sending `SYS_FORK`. A cursor beyond that +end address means frame writes crossed into the adjacent syscall channel; the +process fails with the required and reserved byte counts instead of creating a +child from corrupted continuation state. This is detection, not prevention: +the instrumented unwind has already written past the fixed reserve, so the host +discards the process and its channel. Increasing or making the reserve elastic +is a separate ABI layout change. + For wasm32 (`P = 4`) with a module that declares three additional scalar mutable globals totaling 16 bytes (e.g. `__stack_pointer`, `__tls_base`, one user i64) and one fork-path function with a single `(catch $tag (param i32))` diff --git a/host/src/worker-main.ts b/host/src/worker-main.ts index 9c72ac3c3..376c4eaac 100644 --- a/host/src/worker-main.ts +++ b/host/src/worker-main.ts @@ -688,6 +688,40 @@ function buildImportObject( /** Size of the fork save buffer used by wpk_fork_* instrumentation */ const FORK_BUF_SIZE = FORK_SAVE_BUFFER_SIZE; +/** + * Detect a fork-continuation save-buffer overrun after an unwind completes. + * + * The instrumentation keeps `current_pos` — the pointer-width integer at the + * base of the save buffer (`forkBufAddr + 0`) — seeded to the absolute address + * `forkBufAddr + frames_start_offset` and advanced by every saved frame. After + * unwind it is therefore the high-water linear-memory address written (see + * crates/fork-instrument/src/runtime.rs, `emit_unwind_begin`). The buffer is + * only `FORK_BUF_SIZE` bytes and sits immediately below the syscall channel + * (`forkBufAddr = channelOffset - FORK_BUF_SIZE`), so `current_pos` beyond + * `forkBufAddr + FORK_BUF_SIZE` means the unwind overran the buffer and + * clobbered channel memory. Frames grow upward, away from the header, so the + * base word holding `current_pos` stays readable here. + * + * The instrumented unwind carries no bounds check of its own — runtime.rs + * documents the requirement `frames_start_offset + Σframe ≤ buffer_size` but + * never enforces it. Without this host check the overrun is silent: it + * corrupts the channel and only surfaces later as an unexplained trap or a + * fork child that never makes progress. Returns the overrun in bytes, or 0 + * when the save fit within the buffer. + */ +export function forkSaveBufferOverrun( + memory: WebAssembly.Memory, + forkBufAddr: number, + ptrWidth: 4 | 8, +): number { + const view = new DataView(memory.buffer); + const currentPos = ptrWidth === 8 + ? Number(view.getBigUint64(forkBufAddr, true)) + : view.getUint32(forkBufAddr, true); + const bufferEnd = forkBufAddr + FORK_BUF_SIZE; + return currentPos > bufferEnd ? currentPos - bufferEnd : 0; +} + // Slot below forkBufAddr that stores the head pointer of the dlopen // archive linked list. Fork's memcpy carries the parent's archive into // the child intact; the child walks it to replay each dlopen before @@ -1021,6 +1055,26 @@ export async function centralizedWorkerMain( // Unwind completed (fork) — finalize and send SYS_FORK. unwindEnd(); + // The unwind writes saved frames into a fixed FORK_BUF_SIZE buffer + // that abuts the syscall channel. If the call stack at fork() was + // too deep/wide, those writes overran into the channel. Fail + // truthfully here rather than sending a fork on a corrupt channel + // and spawning a child whose continuation buffer is already + // clobbered (which otherwise surfaces as an unexplained trap or a + // child worker that never makes progress). The process is torn + // down after this throw, discarding the corrupted channel. + const overrun = forkSaveBufferOverrun(memory, forkBufAddr, ptrWidth); + if (overrun > 0) { + throw new Error( + `pid=${pid}: fork() continuation save buffer overflow — the ` + + `call stack at fork() needed ${FORK_BUF_SIZE + overrun} bytes ` + + `but only ${FORK_BUF_SIZE} (FORK_SAVE_BUFFER_SIZE) are ` + + `reserved; the stack is too deep/wide to fork here. This is a ` + + `platform limit of the fork continuation buffer, not a defect ` + + `in the program.`, + ); + } + // Send SYS_FORK through the channel now that memory has the // fork save buffer populated (saved_globals + frames). const childPid = sendForkSyscall(memory, channelOffset); @@ -1840,6 +1894,18 @@ export async function centralizedThreadWorkerMain( const forkState = getState(); if (forkState === 1) { unwindEnd(); + // See the main-process fork path: a too-deep/wide stack overruns the + // fixed save buffer into the channel. Detect and fail truthfully. + const overrun = forkSaveBufferOverrun(memory, forkBufAddr, ptrWidth); + if (overrun > 0) { + throw new Error( + `pid=${pid} tid=${tid}: fork() continuation save buffer ` + + `overflow — the call stack at fork() needed ` + + `${FORK_BUF_SIZE + overrun} bytes but only ${FORK_BUF_SIZE} ` + + `(FORK_SAVE_BUFFER_SIZE) are reserved; too deep/wide to fork ` + + `from this thread. Platform limit, not a program defect.`, + ); + } const childPid = sendForkSyscall(memory, channelOffset); if (childPid < 0) { throw new Error(`Fork failed: errno=${-childPid}`); diff --git a/host/test/fork-save-buffer-overrun.test.ts b/host/test/fork-save-buffer-overrun.test.ts new file mode 100644 index 000000000..5ca1b9a13 --- /dev/null +++ b/host/test/fork-save-buffer-overrun.test.ts @@ -0,0 +1,88 @@ +/** + * Unit tests for `forkSaveBufferOverrun` — the host-side detector that turns a + * fork-continuation save-buffer overrun into a truthful failure instead of + * silent syscall-channel corruption. + * + * The instrumented unwind keeps `current_pos` (a pointer-width integer at the + * base of the save buffer) as the absolute high-water linear-memory address it + * wrote to. The buffer is `FORK_SAVE_BUFFER_SIZE` bytes and abuts the syscall + * channel, so any `current_pos > forkBufAddr + FORK_SAVE_BUFFER_SIZE` means the + * unwind clobbered channel memory. See worker-main.ts and + * crates/fork-instrument/src/runtime.rs. + * + * End-to-end behavior was validated against the real ABI 18 Homebrew launcher, + * whose Bash fork needs 20,012 bytes and now fails with the exact diagnostic + * instead of corrupting its channel. These tests pin the detection arithmetic + * that the fork paths rely on. + */ +import { describe, it, expect } from "vitest"; +import { forkSaveBufferOverrun } from "../src/worker-main"; +import { FORK_SAVE_BUFFER_SIZE } from "../src/process-memory"; + +const FORK_BUF_ADDR = 65536; // arbitrary page-aligned buffer base for the test + +function writeCurrentPos( + memory: WebAssembly.Memory, + addr: number, + value: number, + ptrWidth: 4 | 8, +): void { + const view = new DataView(memory.buffer); + if (ptrWidth === 8) view.setBigUint64(addr, BigInt(value), true); + else view.setUint32(addr, value, true); +} + +describe("forkSaveBufferOverrun", () => { + it("reports no overrun when the save fits within the buffer", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + writeCurrentPos(memory, FORK_BUF_ADDR, FORK_BUF_ADDR + 200, 4); + expect(forkSaveBufferOverrun(memory, FORK_BUF_ADDR, 4)).toBe(0); + }); + + it("treats current_pos exactly at the buffer size as fitting (no overrun)", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + writeCurrentPos( + memory, + FORK_BUF_ADDR, + FORK_BUF_ADDR + FORK_SAVE_BUFFER_SIZE, + 4, + ); + expect(forkSaveBufferOverrun(memory, FORK_BUF_ADDR, 4)).toBe(0); + }); + + it("reports the exact overrun in bytes when the buffer is exceeded", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + writeCurrentPos( + memory, + FORK_BUF_ADDR, + FORK_BUF_ADDR + FORK_SAVE_BUFFER_SIZE + 4096, + 4, + ); + expect(forkSaveBufferOverrun(memory, FORK_BUF_ADDR, 4)).toBe(4096); + }); + + it("reports the exact ABI 18 Homebrew launcher overrun", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + const observedFrameBytes = 20_012; + writeCurrentPos( + memory, + FORK_BUF_ADDR, + FORK_BUF_ADDR + observedFrameBytes, + 4, + ); + expect(forkSaveBufferOverrun(memory, FORK_BUF_ADDR, 4)).toBe( + observedFrameBytes - FORK_SAVE_BUFFER_SIZE, + ); + }); + + it("reads current_pos as i64 on the wasm64 path", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + writeCurrentPos( + memory, + FORK_BUF_ADDR, + FORK_BUF_ADDR + FORK_SAVE_BUFFER_SIZE + 1, + 8, + ); + expect(forkSaveBufferOverrun(memory, FORK_BUF_ADDR, 8)).toBe(1); + }); +});