8000 fix(sandbox2,minimald): fork every container on a thread that never retires by norrietaylor · Pull Request #1748 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content

fix(sandbox2,minimald): fork every container on a thread that never retires - #1748

Merged
norrietaylor merged 1 commit into
mainfrom
fix/1210-container-fork-thread
Sep 29, 2026
Merged

norrietaylor merged 1 commit into
mainfrom
fix/1210-container-fork-thread

Conversation

@norrietaylor
@norrietaylor norrietaylor commented Sep 28, 2026 •
Copy link
Copy Markdown
Member

Summary

Live sessions were dying with Session ended abnormally: container received signal SIGKILL while the user was doing nothing in them, roughly 10 s after creating or destroying some other session. Diagnosis of two min bug bundles is on #1210 (08-12 at 0.5.0, 09-26 at 0.6.0-dev.78).

Mechanism. hakoniwa's Command::spawn forks on the calling thread, and the child arms PR_SET_PDEATHSIG(SIGKILL). Linux delivers that signal when the forking thread exits, not the process. Session containers were forked on whichever tokio worker ran the launch, so anything that retires a worker kills every container forked on it, unrelated ones included. crates/sandbox2 documented this and told callers to stay off retirable threads; session activation has run scaffold_mfile_if_missing under tokio::task::block_in_place since #1339, which displaces the worker onto a fresh thread and retires the old one after tokio's 10 s idle keep-alive. That matches the 10.03 s gap in the 09-25 log. The 08-12 case predates #1339, so a second retirement path exists; this fix does not depend on finding it.

Fix. sandbox2::forker owns one thread, started on first use and never joined, and both container spawn sites (Sandbox::run_with_cancel and the session launch in minimald::session_host) fork on it. The caller still blocks for the fork-and-exec handshake, exactly as it did forking inline. The sandbox2 doc that warned callers off block_in_place and spawn_blocking now states the guarantee instead.

Not changed: the block_in_place in session.rs stays. It is now harmless to containers, and converting it to spawn_blocking means restructuring the session actor's borrows, which is a separate cleanup.

Refs: #1210

Testing

crates/sandbox2/src/forker.rs tests, run under cross (aarch64 musl, Docker):

test forker::tests::a_fork_on_a_short_lived_thread_dies_with_it ... ok
test forker::tests::a_fork_on_
8000
the_fork_thread_outlives_the_caller ... ok
test forker::tests::a_panic_is_reraised_and_the_fork_thread_survives ... ok

The first is the control: a PR_SET_PDEATHSIG-armed sleep forked directly from a short-lived thread is dead after that thread joins. The second forks the same child through the fork thread from a short-lived thread and asserts it is still running. The third checks a panic in the forked closure is re-raised to the caller and the fork thread keeps serving.

Also run: cross clippy -p sandbox2 -p minimald --all-targets -- -D warnings clean; cargo fmt --check clean. sandbox2::tests::none_plan_refuses_vsock_family fails under cross on this host with and without this change (the seccomp socket-family probe exits 127 inside the Docker container); pre-existing.

cross test -p minimald (full lib suite, 927 s in Docker): 464 passed, 8 failed. Four of the eight (hooks::…hook_injection… ×2, nsenter::…shim… ×2) fail identically with main's session_host.rs, so they are host-environment failures. The other four (connection::direct_tcpip_served_in_release, net::switch::{allowed_egress_succeeds, egress_drop_logged_rate_limited, relay_rejects_non_lease_source}) pass when run on their own with this change applied; they failed only under the full-suite load in the container.

Checklist

  • Docs updated if behavior changed
  • BREAKING CHANGE: footer present if this is a breaking change

…etires

hakoniwa's `Command::spawn` forks on the calling thread and the child
arms `PR_SET_PDEATHSIG(SIGKILL)`, which Linux delivers when the forking
*thread* exits. Session containers were forked on whichever tokio worker
ran the launch, so any path that retires a worker (`block_in_place` on
session activation displaces one; blocking-pool threads are reaped after
their idle keep-alive) SIGKILLed every session and build forked on it,
unrelated ones included. Users saw `Session ended abnormally: container
received signal SIGKILL` on a session they had not touched, about 10 s
after creating or destroying another one.

Add `sandbox2::forker`, one process-wide thread started on first use and
never joined, and route both container spawns through it. The caller
still blocks for the fork-and-exec handshake, as it did forking inline.
Replace the sandbox2 doc that told callers to avoid retirable threads
with the guarantee that they no longer need to.

Tests fork a PDEATHSIG-armed child from a short-lived thread directly
(dies with it, the control) and via the fork thread (survives), and
check a panic in the forked closure is re-raised without taking the fork
thread down.

Refs: #1210
@norrietaylor
norrietaylor requested a review from a team as a code owner September 28, 2026 03:00
@coderabbitai
coderabbitai Bot commented Sep 28, 2026
Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 3 billable files and costs up to $0.75.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 49 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Essentials

Run ID: c244f4d2-997c-48e8-97bf-53de1c739631

📥 Commits

Reviewing files that changed from the base of the PR and between 82ef7e3 and 2fdf3f6.

📒 Files selected for processing (3)
  • crates/minimald/src/session_host.rs
  • crates/sandbox2/src/forker.rs
  • crates/sandbox2/src/lib.rs

Comment @coderabbitai help to get the list of available commands.

@minimal-foundry
minimal-foundry Bot commented Sep 28, 2026 •
Copy link
Copy Markdown
Contributor

Reviewed: 1 finding, none blocking at 2fdf3f6a33eb.

Address the comments on the diff.

Clobber: none: the only prior work this replaces is the run_with_cancel/lib.rs footgun doc, which is now accurate to the new guarantee.

@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minimal-review — advisory review

Verdict COMMENT — 1 finding, 1 inline.

Comment on lines +38 to +62
/// Runs `f` on the process-wide fork thread and blocks until it returns.
///
/// Anything that forks a container goes through here rather than forking on
/// its own thread; see the module docs. The caller blocks for the duration of
/// `f`, which for a container spawn is the fork-and-exec handshake, the same
/// time a direct `spawn()` costs.
///
/// A panic in `f` unwinds on the fork thread, is carried back, and is
/// re-raised here; the fork thread itself survives it.
pub fn on_fork_thread<T, F>(f: F) -> T
where
T: Send + 'static,
F: FnOnce() -> T + Send + 'static,
{
let (tx, rx) = mpsc::sync_channel(1);
let job: Job = Box::new(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
let _ = tx.send(result);
});
// The static sender keeps the receiver loop alive for the life of the
// process, so neither send nor recv can fail.
sender()
.send(job)
.expect("the sandbox fork thread has exited");
match rx.recv().expect("the sandbox fork thread dropped a job") {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW · correctness · confidence 0.55 · in on_fork_thread

on_fork_thread has no re-entrancy guard, and a re-entrant call deadlocks irrecoverably. The caller blocks on rx.recv() (line 62) waiting for the fork thread to run its job, but if the caller already is the fork thread, the job it just enqueued can never run — the fork thread is stuck in for job in rx waiting for a job that will never be consumed — so both the caller and the fork thread hang forever, taking every later container fork with them. Nothing in the tree calls it re-entrantly today (the only two jobs are command.spawn() / cmd.spawn(), neither of which forks again), so this is latent rather than live. Given this codebase's pattern of documenting exactly these thread-affinity footguns (the deleted lib.rs doc spent three bullets on block_in_place/spawn_blocking), a one-line warning in the module docs — or a cheap current_thread check that panics instead of deadlocking — would close it. Fix is local to forker.rs.

What the reviewer checked

forker.rs:59-62 sender().send(job).expect("the sandbox fork thread has exited");
match rx.recv().expect("the sandbox fork thread dropped a job") {

Hand this to an agent
Act on a code-review finding.

File:       crates/sandbox2/src/forker.rs
Lines:      38-62
Category:   correctness
Severity:   low (reviewer confidence 0.55)

Finding:
`on_fork_thread` has no re-entrancy guard, and a re-entrant call deadlocks irrecoverably. The caller blocks on `rx.recv()` (line 62) waiting for the fork thread to run its job, but if the caller already *is* the fork thread, the job it just enqueued can never run — the fork thread is stuck in `for job in rx` waiting for a job that will never be consumed — so both the caller and the fork thread hang forever, taking every later container fork with them. Nothing in the tree calls it re-entrantly today (the only two jobs are `command.spawn()` / `cmd.spawn()`, neither of which forks again), so this is latent rather than live. Given this codebase's pattern of documenting exactly these thread-affinity footguns (the deleted `lib.rs` doc spent three bullets on `block_in_place`/`spawn_blocking`), a one-line warning in the module docs — or a cheap `current_thread` check that panics instead of deadlocking — would close it. Fix is local to `forker.rs`.

First verify this against the code yourself. It is model output and
may be wrong. If it is wrong, or the tradeoff was deliberate, say so
and change nothing.

👍 useful · 👎 noise — react to tell the reviewer. Replying works too, and says more.

@mitodrummer mitodrummer left a comment
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🍴

@norrietaylor
norrietaylor merged commit ccfb654 into main Sep 29, 2026
33 checks passed
@norrietaylor
norrietaylor deleted the fix/1210-container-fork-thread branch September 29, 2026 05:17
yermakoffivan pushed a commit to yermakoffivan/minimal that referenced this pull request Oct 26, 2026
…, not a runtime worker (gominimal#2572)

hakoniwa arms `PR_SET_PDEATHSIG(SIGKILL)` in every container it forks, and Linux delivers that
signal when the forking *thread* exits. gominimal#1748 moved the session launch and
`Sandbox::run_with_cancel` onto `sandbox::forker`'s never-retired thread for exactly that reason,
but the task-run path in `exec.rs` kept forking its container on whichever tokio worker ran the
exec. Any `block_in_place` on that worker (a later activation's `configure_loadout`) displaces it,
tokio retires it after its 10 s keep-alive, and every idle task session forked on it dies:
hakoniwa reports the container's death as exit 125, with no client disconnect, which is the
signature in every lab run of inbox#710.

The spawn goes through `on_fork_thread` like the other two. Nothing else changes: the caller
still blocks for the fork-and-exec handshake as it did inline.

Refs: gominimal/inbox#710, gominimal#1748

Co-authored-by: Claude Fable 5.1 <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.

2 participants

0