Exarch’s panic-recovery contract for the persistent shell
exarch evaluates each tool call as a ral top-level turn against one
persistent Shell that survives the whole session. bus::pump runs the
worker under catch_unwind and, on a caught panic, reports a worker panicked
line and lets the REPL continue the session on that same Shell. The deep
review (260611 §8, finding X8-panic / recommendation A4) showed this left the
live shell corrupted: a panic mid-eval skips ral’s save/restore epilogues, so
the next turn inherits the panicking call’s half-applied dynamic context — a
leaked with_capabilities grant frame, the hijacked stdout/stderr/stdin tees,
a foreign surface sink, a stale script-context location, the already-fired
watchdog cancel-scope, leaked env/cwd overrides, or a deleted handler.
ral itself does not rely on Rust unwinding to restore the dynamic context — its in-tree justification holds inside the language. exarch is the host that falsifies it: it is the one embedder that catches the unwind and keeps going.
The decision
We take A4 option (a) — the shell is poisoned on a caught panic, and exarch reconciles it back to the last clean boundary — rather than option (b) (convert every ral save/restore pair to RAII). A Rust panic is not a ral escape and the panicking computation has no meaningful continuation; the cheap, honest move is to discard its effects, not to unwind them one frame at a time through core.
The contract has two halves, split by who owns each piece of state:
-
The IO frame self-heals via RAII. The stdout/stderr/stdin tees, the
surfacehost sink, the script-context location triple, and the watchdog cancel-scope are all installed byshell_eval::run_shell’s ownIoGuard, whoseDroprestores every field. These are host-owned, installed per tool call, and never meaningful past the call — so a guard is exactly right, and it fires on the normal return and on unwind, before the stack leavesrun_shell. (Mirrors core’s existingcapture.rs/RedirectFrameRAII, converted after an earlier real panic bug.) -
The rest of the dynamic context is rebuilt from a durable snapshot.
Sessionholds adurable: Mobilefield, refreshed inside the worker — inSession::run_shell, right before eachshell_eval::run_shelldispatches — so it always carries themobilethat completed tool calls left behind and never the one a call is mid-way through. The field lives onSessionprecisely so it survivespump’scatch_unwindboundary: the worker writes it, and afterpumpreturnsOk(None)(the caught-panic signal)Session::run_turnreads it and assignsself.shell.mobile.context = self.durable.context.clone(). Completed calls’ bindings (inmobile.scope) and cwd (incontext.cwd) survive; the panicking call’s grant frame, env/cwd, and handler-stack mutations roll back.
run_turn’s existing single-exit quiesce then winds the event log back to
ReadyForUser (turn-ends-ready), so the next prompt is
admissible against a shell whose dynamic context is clean.
Where
exarch/src/shell_eval.rs—IoGuard(install +Drop); the per-call IO frame.exarch/src/session.rs— thedurable: Mobilefield, its refresh inrun_shell, and the rebuild inrun_turnonpump→Ok(None).exarch/src/bus.rs—pump’scatch_unwind, returningOk(None)on a caught panic.
Covered
session::tests::worker_panic_preserves_completed_bindings_and_clean_context:
a scripted run whose first tool call binds a name and whose second panics
mid-eval — the binding survives, the grant stack is back at its baseline depth,
the session is ReadyForUser, and a third turn completes on the healed shell.
The hard rule
Any state the host installs onto the persistent shell for the duration of one
tool call must restore on unwind, not only on the normal return: install it
through an RAII guard (the IO frame) or reconcile it from the durable Mobile
snapshot at the run_turn panic boundary (the dynamic context). Never add a
hand-written restore epilogue that a panic between install and restore would
skip.
See also shell-eval, agent, frontend (the event-log state machine), and turn-ends-ready.