0079 — Fatal-signal terminal restore
A SIGSEGV/SIGBUS/SIGABRT in the client does not unwind, so neither RawModeGuard::drop nor the panic hook runs and the user is left in raw mode inside the...
Full source summary
A SIGSEGV/SIGBUS/SIGABRT in the client does not unwind, so neither RawModeGuard::drop nor the panic hook runs and the user is left in raw mode inside the alt screen with mouse tracking armed. phux now installs an async-signal-safe handler that writes the DEC private-mode resets with a raw write(2), restores the saved termios, and re-raises with default disposition so core dumps and exit status are unchanged. The handler lives in phux-crash, a crate vendored from xAI's Apache-2.0 xai-crash-handler — the only crate in this workspace that is not MIT OR Apache-2.0.
Status: Accepted Date: 2026-08-09
Context
phux already restores the terminal on every teardown path that gives it a chance to run:
- Normal exit and detach —
RawModeGuard::droprestores termios from the instance field and callswrite_terminal_reset. - Panic —
install_panic_hook_oncelogs to the file sink, callsterminal_reset_on_signal, then chains the previous hook. - SIGINT / SIGTERM / SIGHUP — handled in the main
select!loop, each arm callingterminal_reset_on_signalbefore exiting with the shell-conventional code.
Every one of those depends on ordinary control flow reaching a cleanup path.
A fatal signal reaches none of them. SIGSEGV does not unwind, so Drop never
runs; it is not a Rust panic, so the panic hook is never called; and it is not
in the select! set, so no arm fires. The process dies with default
disposition and the outer terminal keeps whatever phux last set: ?1049h
(alt screen), ?25l (cursor hidden), ?1002h/?1006h (mouse reporting), and
raw termios. The user gets an unusable shell and has to know to type reset
blind.
This is not hypothetical for phux specifically. phux-client is
#![forbid(unsafe_code)], so its own code is not where a segfault comes from.
The crash surface is the native libghostty-vt FFI boundary — a Zig terminal
engine (ADR-0004) that the client runs
per-attached-pane in-process (ADR-0013).
We have already shipped fixes for two engine-level crashes (phux-y06, a
PageList.resizeCols overflow on a simultaneous shrink-both-dims resize;
phux-h5hj.8). Each of those, before its fix, wedged the terminal of every
user who hit it. The engine is upstream code on a pinned git rev; the next
such bug is a question of when, not whether.
The existing comment at the panic-hook call site already names the risk — “renderer bug, libghostty FFI surprise, etc.” — but a panic hook cannot see the FFI surprise in the form it actually takes.
Decision
-
Install an async-signal-safe fatal-signal handler for SIGSEGV, SIGBUS, and SIGABRT. It writes a fixed byte string of DEC private-mode resets directly to fd 2 with
libc::write, restores termios withtcsetattrfrom a snapshot taken before raw mode was entered, then restoresSIG_DFLand re-raises. No allocation, no locks, no libc buffering — nothing that is unsafe to call from signal context. Re-raising rather than exiting keeps core-dump behaviour and exit status exactly as they were. -
Vendor
xai-crash-handlerascrates/phux-crashrather than write our own. It is a complete, tested implementation of a fiddly piece of systems code: an alternate signal stack viammapso the handler survives a stack overflow,SA_RESETHANDso a fault inside the handler cannot loop, ordering constraints on the reset sequence that are enforced by unit tests, and an integration suite that spawns real subprocesses which really do raise each signal and asserts on what they left behind. Upstream is Apache-2.0 and has no internal dependencies — onlybacktraceand platformlibc/windows-sys. -
The reset sequence is deliberately a superset of what phux enables. phux sets
?1049,?25,?1002,?1006,?1003, and?2026. The sequence also clears?1000,?1015,?2004,?1004, and pops the kitty keyboard stack — modes phux parses on input but never requests. Resetting a mode that was never set is a no-op at the terminal; missing one costs the user a wedged session. Two ordering constraints are load-bearing and test-enforced: synchronized update (?2026l) ends first, so a multiplexer stops buffering before the rest arrives, and the kitty pop precedes?1049l, because the protocol stack is per-screen. -
Escape-code restoration is armed and disarmed with the alt screen.
phux_crash::install_terminal_restore_only()is called inattach_sessionbeforeRawModeGuard— the handler snapshots termios at install time, so installing it later would capture the raw flags and “restore” the user into raw mode, the exact wedge this ADR exists to prevent. That call arms the termios-only variant.RawModeGuard::install_with_stdoutupgrades to the escape-code variant once the alt screen is genuinely up, andDropdowngrades it again, so a crash on the normal screen never sprays DECSETs across it. -
phux-crashis Apache-2.0 only, and says so. ItsCargo.tomlsetslicense = "Apache-2.0"explicitly rather than inheriting the workspace’sMIT OR Apache-2.0, it ships its ownLICENSEandNOTICE, and every modified file carries aMODIFIED FROM UPSTREAMbanner. The full modification list lives in the crate’sCargo.tomlheader and is the re-vendoring checklist. -
Crash reporting is deliberately out of scope here. The upstream crate can also write a binary crash blob and symbolicate it on the next start (
install()+check_previous_crash()). We install only the terminal- restore half. Turning on blob capture means deciding where blobs live, how long they are kept, whether the next launch surfaces them, and whether any of it is reported anywhere — a product question, not a terminal-hygiene one. The capability is present in the crate when we want it.
Rationale
The alternative framings all lose to the same fact: the failure mode is not “we crashed”, it is “we crashed and took the user’s terminal with us”. The first half is a bug to fix upstream; the second half is a property phux can guarantee unilaterally, cheaply, for any cause of death.
Vendoring beats writing it ourselves because the difficulty here is not the idea — everyone knows you want to reset the terminal on a segfault — it is the signal-safety discipline. Anything that allocates, takes a lock, or goes through buffered I/O is a potential deadlock in a handler that ran because memory is already corrupt. Upstream’s version has the alternate stack, the recursion guard, the reset ordering, and a test suite that raises the actual signals. Reimplementing that to avoid a license note would be vanity.
The unsafe is real, and confining it to its own crate is the point: it is
raw pointer work and sigaction FFI that cannot be expressed in safe Rust,
and putting it behind a crate boundary lets phux-client keep
#![forbid(unsafe_code)] unchanged. This is the same shape as
portable-pty-adopt (ADR-0032):
narrow, dependency-clean, unsafe where it must be, isolated from everything
else.
Tradeoffs
- A non-dual-licensed crate in a dual-licensed workspace. Anyone who needs
the MIT option cannot link
phux-crash. Since it is a leaf dependency ofphux-client, that effectively means the binary as shipped is Apache-2.0. This is a real narrowing of what ADR-0071 freezes, and the reason it is stated in three places rather than one. - Process-global signal handlers. Registering SIGSEGV/SIGBUS/SIGABRT is a process-wide act. Upstream’s integration tests cover coexistence with a running tokio runtime and prove other signals are not clobbered, but any future in-process host that wants its own fatal-signal handling will have to reconcile with ours.
- Nothing is captured about why we crashed. Per decision 6, the terminal is saved and the crash is not explained. A user who hits an engine fault still has nothing to send us beyond “it died”. Blob capture is the follow-up.
- We now carry vendored code. It must be re-synced by hand if upstream fixes something. The modification list is kept in the crate’s Cargo.toml specifically so that re-sync is a diff-and-reapply rather than an archaeology exercise.
Alternatives considered
- Write a minimal handler ourselves. Perhaps 40 lines for the happy path.
Rejected: the happy path is not the hard part. Without the alternate signal
stack, a crash caused by stack overflow — a real possibility in a recursive
layout tree — cannot run the handler at all, and without
SA_RESETHANDa fault inside the handler spins. - Use an existing crash crate (
human-panic,color-eyre,sentry). Rejected: the first two run inside the panic hook, pre-unwind, which is precisely the case phux already handles. They never execute for a segfault.sentry’s native handler would work but brings a reporting SDK and a network surface for what is a five-escape-sequence problem. - Reimplement upstream’s logic from scratch to keep MIT. Rejected as license-driven engineering: it would produce less-tested code with the same behaviour, and the Apache grant is confined to one leaf crate either way.
- Do nothing; treat it as an upstream libghostty problem. Rejected: it is true that the fix for any specific crash belongs upstream, and equally true that the user’s terminal should survive a crash we have not fixed yet. These are not competing.