From f5ac952cef1a720a8883f86c20bc15537f593784 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sun, 5 Jul 2026 16:58:17 -0400 Subject: [PATCH] fork: fail truthfully when a fork continuation overruns its save buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wpk_fork unwind writes the saved call stack into a fixed FORK_SAVE_BUFFER_SIZE (16 KiB) buffer that sits immediately below the syscall channel (forkBufAddr = channelOffset - FORK_BUF_SIZE). The instrumented unwind carries no bounds check of its own — crates/fork-instrument/src/runtime.rs documents the requirement `frames_start_offset + Sigma(frame) <= buffer_size` but never enforces it. So a fork() from a call stack too deep or wide to fit silently overruns the buffer into the channel, corrupting the syscall channel and later surfacing as an unexplained trap or a fork child that never makes progress — never as the real cause. Detect the overrun host-side, right after the unwind completes and before SYS_FORK is sent: current_pos (the write cursor the instrumentation keeps at the buffer base, which frames never clobber) exceeding the buffer size means the unwind wrote past it into the channel. When it does, fail the fork with a clear diagnostic naming the byte overage and the platform limit, instead of forking on a corrupted channel. Applied to both the main-process and thread fork paths, which Node and browser hosts share. Validated in a real browser by temporarily shrinking the buffer: a fork that overruns now reports "fork() continuation save buffer overflow ..." and dies cleanly, while forks that fit still succeed (no false positives). Adds a unit test for the detection arithmetic (wasm32 + wasm64); the existing fork/spawn/thread suites pass unchanged. Co-Authored-By: Claude Opus 4.8 --- docs/fork-instrumentation.md | 9 +++ host/src/worker-main.ts | 66 ++++++++++++++++ host/test/fork-save-buffer-overrun.test.ts | 88 ++++++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 host/test/fork-save-buffer-overrun.test.ts 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); + }); +});