Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/fork-instrumentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))`
Expand Down
66 changes: 66 additions & 0 deletions host/src/worker-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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}`);
Expand Down
88 changes: 88 additions & 0 deletions host/test/fork-save-buffer-overrun.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading