Skip to content

fork: fail truthfully when a fork continuation overruns its save buffer#845

Open
brandonpayton wants to merge 1 commit into
mainfrom
fork-save-buffer-truthful-failure
Open

fork: fail truthfully when a fork continuation overruns its save buffer#845
brandonpayton wants to merge 1 commit into
mainfrom
fork-save-buffer-truthful-failure

Conversation

@brandonpayton

Copy link
Copy Markdown
Member

What

The wpk_fork unwind saves the 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 has no bounds check of its owncrates/fork-instrument/src/runtime.rs documents the requirement frames_start_offset + Σ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. Today that surfaces only later and elsewhere — an unexplained wasm trap, or a fork child whose channel never works so its worker "never starts" — never as the real cause.

This PR detects the overrun host-side, right after the unwind completes and before SYS_FORK is sent, in both the main-process and thread fork paths (shared by the Node and browser hosts). current_pos — the write cursor the instrumentation keeps at the buffer base, which frames grow away from and never clobber — exceeding the buffer size means the unwind wrote past it. When it does, the fork fails with a clear diagnostic instead of proceeding on a corrupted channel:

pid=N: fork() continuation save buffer overflow — the call stack at fork() needed X bytes but only 16384 (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.

This is a truthful-failure hardening (per the platform's "truthful failure over convenient illusion" contract): it turns silent corruption into a diagnosable failure at the fork point. It does not raise the limit — a deeper/wider fork still fails, just visibly and for the right reason.

Validation

  • Browser (real headless Chromium, same nested-worker transport as the demos): temporarily shrinking the buffer, a fork that overruns now reports the diagnostic and exits cleanly (exit=-1), while forks that fit still succeed (depth 2/5/10 → 6/6 children). Reported byte count scales exactly with stack depth.
  • Unit test (host/test/fork-save-buffer-overrun.test.ts) pins the detection arithmetic for the boundary, overrun, and wasm32/wasm64 cases.
  • Existing fork/spawn/thread suites (nested-fork-repro, fork-from-thread, fork-dlopen-replay-e2e, centralized-spawn, spawn-host-parity, pthread) pass unchanged — no false positives on normal forks.

Scope / notes

  • Host-runtime change (host/src/worker-main.ts); no ABI change, no re-instrumentation, no kernel change.
  • Detection, not prevention: the overrun still occurs during the in-wasm unwind (before any host code runs), but it is now caught immediately and the process is torn down, discarding the corrupted channel — no fork is spawned on it. Fully preventing the overrun would require a bounds check inside the instrumented unwind (crates/fork-instrument), a larger, ABI-adjacent change; this PR is the safe, self-contained first step.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant