fix(cmux-tui): a kill of everything restores each terminal's exact text, then the session line, in the same directory string (REOPEN-F3)
Summary
REOPEN-F3 (P0, data loss; STATUS 4421, film reopen/dogfood-f350103/kill-daemons-light/a1-011556/reopen-1.mp4): after a kill -9 of the app, the daemon, the holder and every terminal host, each reopened terminal showed only — session restored (previous process ended) — and a new prompt. The last screen (400 numbered lines) and the 363-row scrollback were gone, and the directory read /private/tmp instead of /tmp.
Lawrence's rule (cmux-ad 01:30Z): when the terminal hosts die, the stored output restores the text exactly, then a clear "session ended" line, and the new shell starts in the same path string.
Cause 1: the text was lost
The output store's default mode (snapshot, from #22937) wrote a terminal's screen at most every 30 s after its first new output. A kill of the daemon and its hosts in that window found no stored screen, so the respawn seeded only the marker line.
Fix: a tail journal with the snapshot as its compaction (cmux-tui-core/src/output_store/tail.rs):
-
The output after the last snapshot is appended to
<terminal>.tailby the store's background writer every 100 ms (write(2) only; the PTY path stays memory-only). Each tail is fsynced at most once per second. -
The snapshot records the stream offset its screen ends at. The offset is read under the parser lock while no parsed chunk is in flight (the journal capture epoch is even), so the tail continues the snapshot byte for byte. When the snapshot is in place, the tail file goes.
-
A snapshot is written for a terminal that this daemon has no snapshot of (a new generation), after a resize, at most every 30 s while the content changes, when the tail passes 1 MiB, when 1 MiB of unchanged redraws waits in memory, and at checkpoint, clear-history and quit.
-
Restore: the snapshot replay plus the tail bytes from exactly its offset, rebuilt in a scratch terminal of the snapshot's size. A gap or another generation means the snapshot alone.
-
Cursor redraws (same content digest) still write nothing: the idle tests pass in every mode.
-
The memory budget pass of a write pass runs at most once per snapshot interval, as before (the growth trigger covers the time between).
-
Worst-case loss: the output of the last 100 ms plus one writer pass when every process dies (kill -9 of all, a crash). Bytes that change nothing the content digest sees (text, size, active screen, history, cursor position; for example a cursor redrawn in place or a color-only change) wait in memory up to 1 MiB, so an idle TUI writes nothing; a kill can lose those too. An OS crash or a power loss can add up to 1 s (the fsync interval). The loss does not depend on the output stopping. The snapshot still holds the screen plus 2,000 lines (unchanged limit);
fullmode is unchanged. -
A disk that refuses writes: each terminal backs off its snapshot tries (1 s, doubling to 30 s; quit and journal checkpoints still try). Bytes that cannot be stored are dropped; the snapshot at the end of the backoff covers them. See the failure-mode table.
-
Disk cost: about 16-25 KB per second per pane while a full-screen TUI repaints its whole screen at 50-60 Hz (table below); 0 bytes for an idle terminal or a cursor-only redraw.
Cause 2: /tmp became /private/tmp
The PTY child got no PWD for its cwd. A shell keeps an inherited PWD only when it names its working directory, else it shows getcwd(3), the real path. cmux-pty now sets PWD to the cwd path string (absolute paths only). A caller's own PWD, set or removed, wins.
Failure-mode table (snapshot mode, per terminal)
One rule bounds every row: when unwritten bytes cannot continue the tail, they are dropped (also right in append, past 8 MiB), and while the log still needs a snapshot, every pass asks for the next one at the earliest time one may be written (100 ms; after the backoff when the disk fails; after the size has held 1 s after a resize), whether or not the terminal prints again. That snapshot is taken from the live terminal, so it covers the dropped bytes' effect on the screen and on its recent history. Nothing waits in memory for it. A snapshot holds the screen plus about 2,000 lines of scrollback (the unchanged snapshot limit: a VT replay of up to (2,000 + rows) x cols x 3 bytes, 256 KiB to 4 MiB). A restore is that snapshot plus the tail after it. Full mode keeps every byte in segment files; it is unchanged, apart from row 1.
| input | memory | disk | worst-case loss on a kill of all processes | test (red on the base of the fix) |
|---|---|---|---|---|
| normal output, a kill at any time | unwritten bytes of one pass (100 ms) | tail at most 1 MiB raw plus one append (compacted into a snapshot at 1 MiB or 30 s); snapshot up to 4 MiB gzip | the last 100 ms plus one writer pass | kill_all_restores_the_exact_screen_and_scrollback_then_the_session_line, kill_all_while_output_streams_restores_every_line_but_the_last_moment |
| flood (the writer falls behind) | at most 8 MiB unwritten, checked on every PTY read (append; the writer is woken at 4 MiB), then dropped, plus the writer's in-flight copy (for output that does not compress, up to about 3 x 8 MiB for a moment, one terminal at a time); a drop while a snapshot is written still asks for the next one | each append at most 8 MiB raw | the last 100 ms plus one pass; past 8 MiB unwritten, the snapshot at the next pass covers it (screen plus about 2,000 lines) | a_flood_past_the_cap_falls_back_to_the_snapshot, a_flood_during_a_backoff_keeps_at_most_the_cap_in_memory, a_drop_during_a_snapshot_write_still_asks_for_a_snapshot; full mode: a_flood_flush_writes_bounded_frames_and_asks_for_the_next_pass_at_once |
| idle TUI (cursor-only redraws) | held redraws at most 1 MiB, then dropped and a snapshot at the next pass | 0 bytes | nothing (the content did not change) | idle_redrawing_terminals_write_nothing_in_snapshot_mode, a_redraw_of_exactly_the_stored_bytes_waits_in_memory, held_redraws_past_the_idle_cap_fall_back_to_the_snapshot |
| a chunk in flight at the capture | as normal | as normal | as normal (never held as a redraw) | a_chunk_in_flight_at_the_capture_still_reaches_the_tail, bytes_after_the_capture_still_reach_the_tail |
| full disk / writes refused | at most 8 MiB unwritten (checked on every PTY read) plus the writer's one in-flight copy, then dropped | no growth | everything since the disk refused writes, as with any store (nothing can be written); a snapshot is tried after a backoff of 1 s doubling to 30 s, and at quit and checkpoints | a_failing_disk_does_not_retry_the_snapshot_on_the_next_pass, a_flood_during_a_write_backoff_does_not_wake_the_writer_per_chunk, a_flood_during_a_backoff_keeps_at_most_the_cap_in_memory, a_tail_that_does_not_continue_keeps_no_bytes |
| slow disk | at most 8 MiB unwritten plus the writer's one in-flight copy (one terminal at a time), then dropped | as normal | one pass plus the slow write | as for the flood row |
| huge frame | the 8 MiB cap | an append is at most 8 MiB raw; a tail stops at 32 MiB raw plus one append while snapshots fail (below the 64 MiB read limit) | as for the flood row | a_flood_past_the_cap_falls_back_to_the_snapshot |
| unreplayable screen (a Kitty upload, never ended, larger than the replay budget) | the 8 MiB cap | tail up to 32 MiB raw | the restore keeps the text; the half-sent image is aborted in the scratch terminal. While the upload stays unfinished, once the tail cannot continue (past 32 MiB of tail, after a resize, or in a new generation), no snapshot and no tail can take more output: the loss has no bound until the program ends the upload (an edge case) | an_unreplayable_screen_does_not_stop_the_tail, a_tail_that_ends_inside_an_oversized_upload_still_restores_its_text; coverage: a_screen_that_ends_inside_a_control_string_keeps_the_marker_line |
| resize storm (a divider drag) | about 0 (dropped until the size holds) | one snapshot when the size has held 1 s, not one per pass; it comes even if the terminal stays idle | output in the 1 s after the last resize plus one pass | a_resize_storm_writes_one_snapshot_after_the_size_settles (at most 2 snapshots during the storm, then the snapshot at the settled size) |
| crash during a write | as normal | a cut tail frame ends the read; a snapshot is temp file, fsync, rename, directory fsync before its tail is removed | the last pass | tail_bytes_continue_the_snapshot_offset_and_stop_at_a_gap_or_cut_frame |
| corrupt or truncated tail file, another generation, a gap | none | none | the restore uses the snapshot plus the longest valid tail prefix that continues it exactly, or the snapshot alone | tail_bytes_continue_the_snapshot_offset_and_stop_at_a_gap_or_cut_frame |
| many terminals at once | per terminal as above (normally one pass of output; at most 8 MiB while flooding) | per terminal as above; idle terminals write nothing | per terminal as above | no extra test: the per-terminal tests hold per log; kill_all_heavy_load_disk_writes_per_pane is a measurement (12 panes), not a bound |
Known limits, not changed here: under a constant flood every capture try can find a parsed chunk in flight; the tail then grows to its 32 MiB cap and the covering snapshot comes when the flood eases (memory and disk bounds hold). The 4 MiB writer wake has no test of its own (an improvement, not a table bound).
Other known limits: tail files are not counted in terminal.persistence.maxTotalBytes (normally at most about 1 MiB per terminal; up to 32 MiB only while snapshots fail); history beyond about 2,000 lines comes back only in full mode.
Disk cost and the alternatives (fleet, macOS 26)
12 panes, each a TUI that repaints all 40 rows with new content at about 50-60 Hz; daemon logical write bytes per pane per second (proc_pid_rusage ri_logical_writes), 8 s windows, one test at a time (step ec1a2ebc0c8fe45c01955399, NEXTEST_TEST_THREADS=1, scratch branch cp/reopen-f3-measure-matrix), two runs each:
| policy (same 100 ms loss bound) | scroll (1 row into history per frame) | inline repaint (cursor-addressed, no scroll) | alternate-screen repaint |
|---|---|---|---|
| this PR: snapshot at 30 s or at 1 MiB of raw tail | 22.9-24.8 KB | 19.5-19.9 KB | 15.8-19.0 KB |
| compact at 64 KiB of raw tail | 97.7-114 KB | 91-98 KB | 83-94 KB |
| compact when the tail is 1x the snapshot file | 131-166 KB | 211-221 KB | 205-209 KB |
| compact when the tail is 4x the snapshot file | 72-75 KB | 96 KB | 83-104 KB |
| a snapshot instead of the tail when it is cheaper | 24.4-26.0 KB | 17.0-17.8 KB | 19.6-22.7 KB |
| memory mode (nothing on disk) | 0-42 B | 0-85 B | 0-170 B |
A snapshot of these screens is about 1.8-3 KB, the same as 100 ms of tail. So a reset at a full repaint or an earlier compaction does not remove bytes: each compaction adds a snapshot write. The current rule is the lowest or equal at the same loss bound. For 12 such panes that is about 190-300 KB/s (0.7-1.1 GB per hour) while they all repaint; #22937's problem was 13-25 MB/s while idle. An earlier 20 s run with parallel tests gave 36.9 KB (base 85 B; steps 7998d492ce9f2d53cbaea7a0, 471c39c64daa23b523b70b4c).
Tests (red on the base, green on the head)
kill_all_restores_the_exact_screen_and_scrollback_then_the_session_line: 400 lines in a 40-row shell, a kill of the daemon, the shell and its host 2 s later, with the production 30 s interval. The restored transcript must equal the transcript before the kill line by line, and the next line must be the session line.kill_all_while_output_streams_restores_every_line_but_the_last_moment: a loop prints until the kill. Every line from the first must come back in order with no gap. At most 0.5 s of lines may be lost.kill_all_respawned_shell_keeps_its_directory_path_string: a shell started in<dir>/link(a symlink) prints$PWD = <dir>/link, before and after the kill (each probe has its own label).output_store::snapshot::tests: a chunk in flight at the capture, or bytes after it, still reach the tail; a redraw of exactly the stored bytes waits in memory; a failing disk does not retry the snapshot on the next pass, stops the tail and keeps the bytes; held redraw bytes stay bounded; a snapshot in any mode removes a tail file; a flood during a write backoff does not wake the writer per chunk.cmux-ptyspawn_sets_pwd_to_the_cwd_path_string_not_its_real_path: the child'sPWDis the path string, and a caller's ownPWDwins.output_store::tail::tests: tail bytes continue the snapshot offset exactly and stop at a gap or a cut frame. A restore writes the tail over the snapshot screen.
Commands: cmux-ci run --class isolated --script scripts/ci/cmux-tui-rust-check.sh --ref SHA --os macos --arg test --arg <filter>.
- Red at e17b76a4a5b (tests only): steps a8acfc4f92b5d59c500dcdae (
kill_all, 3 of 3 FAIL: 2 lines instead of 405; 0 of 184 streamed lines;/private/tmp/.../realinstead of/tmp/.../link) and 0977154b3cb7e51e7c313a2e (spawn_sets_pwd, FAIL: PWD/private/tmp/.../real). - Green at 5493e4322a9: ed45e11a7c411460ed545884 (
kill_all3 of 3 PASS), 292f0d48943892191e85202c (spawn_sets_pwdPASS), 6b9c4379c23f755310437430 (output_store: 27 of 27 PASS, every existing output store test in all modes, plus the new tail tests). - Review BLOCK at 4867b5e8d31 (a parsed chunk in flight at the capture could be held as a redraw until the next output). Red at 7296becda16: step 89dc1882ff0c6ce2e05974ab,
a_chunk_in_flight_at_the_capture_still_reaches_the_tailstoredfirstwithoutchunk, andbytes_after_the_capture_still_reach_the_tailstoredone twowithoutthree. - Green at 8aed6172b46 (the review fixes): 01b63663bd6feab877301263 (
kill_all3 of 3), b590e8fdf570f8ca434e056b (spawn_sets_pwd), b9083c99a70df17a659e0cf8 (output_store30 of 30). - Review BLOCK at 8aed6172b46 (a full disk made every terminal with unwritten output re-try a whole snapshot each 100 ms). Red at cd624717230: step 264d56ee96ce44544963dd67,
a_failing_disk_does_not_retry_the_snapshot_on_the_next_passFAIL. - Green at de88631da57 (write backoff 1 s doubling to 30 s, plus regression tests for each earlier review fix): 8846914ae40fcb95f06c837e (
kill_all3 of 3), fc03076e0c530858506052d2 (spawn_sets_pwd), 290701a5b641820758d45b98 (output_store33 of 33). - Review BLOCK at de88631da57 (during a write backoff a flood woke the writer on every chunk; each pass moved the capped 8 MiB buffer). Red at 59d46479ea9: step df58951aa403967c20c8f487,
a_flood_during_a_write_backoff_does_not_wake_the_writer_per_chunkFAIL. - Green at 77a38ad8a31: 1c0773752f42aa67b48c7864 (
kill_all3 of 3), 47cf628c32a0ddd097e17444 (spawn_sets_pwd), 203e0753068a8a06edbee48d (output_store34 of 34). - Review BLOCK at 77a38ad8a31 (a full-mode flood could build frames past the 64 MiB read limit; an unreplayable screen stopped the tail). Red at 46efea3da1c: steps dbed302eac9c908628289a7f and 2b090c3d9bcfb85c81c2620d. Green at 86e5b9570ad: 3d36b6cb12f85157b8d68d66, 4bef4c97816896186e7bfc9a, 9aa77485865b1563e6f84f5e, 8cb2860a7598fc3335bf91a8.
- Failure-mode pass (lead): red at 2bca9ee2560: steps a44b0cc399fd827c7c376cf7 (4 unit tests FAIL) and 6b8bdcc52b081e2a375e5ae0 (the resize storm FAIL); the seed test passed on the base (step 145f2c7e8dbf466c3b3425b3), so its change was taken out. Red at d619861b073: step 9dd0562491c6c6667b2b7cb1 (a drop did not ask for its snapshot).
- Green at the final code (52f25fbc021; 2c2853fc3a4 only corrects a test bound):
output_store39 of 39 (df515a6121d8e787c7531eb2, at 2c2853fc3a4),kill_all_restore5 of 5 (9e49ad1c76650daf062c7175),terminal_respawn40 of 40 (604424ee2b3bf6a19ff825e7),spawn_sets_pwd(a19db1973b1632f1bb47a799). - Review round 5 (against the table) BLOCK at 2c2853fc3a4: two rows did not hold (memory during a write backoff; an idle terminal never got a snapshot a pass could not take). Red at 54f613f14e3: steps dec2a2594f51a0d58a4f3ca3 (
a_flood_during_a_backoff_keeps_at_most_the_cap_in_memory) and 6a710f49b501de592eb867e4 (the resize storm test, now also waiting for the snapshot at the settled size). - Green at 7e8c89b1b8d:
output_store40 of 40 (5b4a56df8d0fcbc93ce911b4),kill_all_restore5 of 5 (bd5bebbeb2b054e043ca6c3c; the first try 1129e555f08e73e33713ab52 failed on the Blacksmith overflow runner, an infrastructure failure),spawn_sets_pwd(d2b54850c79aeb618ec47807),terminal_respawn(rerun after the same runner failure; result in a comment). - Review round 6 (against the table) at 7e8c89b1b8d: 11 of 12 rows held; BLOCK on the flood row (a drop in
appendduring a snapshot write lost the request for the covering snapshot). Red at 8877c8dcb64: step f24af34b62a2984ca984daa1 (a_drop_during_a_snapshot_write_still_asks_for_a_snapshotFAIL). - Review (subagent, correctness first): SHIP at f409896d77173587347758f44ee9cf94e416220e (round 7, against the failure-mode table: all 12 rows hold).
- 56ba006814b (head) fixes two clippy
-D warningserrors the PR's CI found in my code (an unneeded qualification in a test, a type alias): fleet clippy green (820b13695d1fd128565f477e),output_store41 of 41 (11a0cabccdc9eb6d49f7cd40). A review of this exact head follows in a comment. - Other PR reds, base findings (also on #31694 and #31696):
acpmux tests (lib, hub_integration)(workflow-scope test),nightly-guards / release-notary(whats-new digest test), and the earlier Cargo.lock red of #31462. - Green at f409896d771 (final head):
output_store41 of 41 (adcc1c39378bfa0a08ffdfc9),kill_all_restore5 of 5 (47cc3725103dccfcc6cedd74),terminal_respawn40 of 40 (4b5c329a4c7e4a3f3b81a918),spawn_sets_pwd(948a745a00d0e3445e229956). - App, REOPEN VIDEO journey
reopen-kill-all-scrollbackon build 86af1b06f57a87114961ecb0 (this fix at 5493e4322a9): the data is back in 3 of 3 (scrollback 1..400 once each, in order, then the session line). 2 of 3 fail on REOPEN-F5: thepwdtyped after the reopen does not reach the new shell (STATUS 4618). - F5 is REOPEN-F4 (focus), not this path (STATUS 4658; owner of the fix: reopen-f4). In the two failed attempts the terminal's cursor is a hollow box (Ghostty's unfocused cursor) in the end frame, and the key's reply took the menu path. The terminal was not the key view; F4's journals show AppKit giving the first key view to the sidebar on reopen, where it stays. In the passed attempt the cursor is a focused bar, and the frame shows
$ pwd,/tmp,$: the respawned shell got the line, and its directory is the path string/tmp. The daemon test shows that input to the respawned terminal works. The lead lifted the F5 hold on this PR (STATUS, lead after 4658).
Observed / Not run
- Observed: the red and green runs above on fleet Macs (macOS 26), and the disk measurement.
- Respawn time (daemon alone, step 795917bfad594f610cd2554f, scratch
cp/reopen-f3-measure-latency): from the new daemon's spawn to the respawned runtime in its tab with the restored text and the session line: 1 terminal 144 ms, 4 terminals 152-153 ms, 12 terminals 180-244 ms. - Not run: the full cmux-tui suite, clippy and fmt (the PR's CI runs them). The real app: a recording build of this head is job 86af1b06f57a87114961ecb0 (
cp/reopen-f3-recording-build, this fix plus the REOPEN VIDEO bench mapping). The REOPEN VIDEO owner runs the journeyreopen-kill-all-scrollbackand films the kill-all case on it. Results go in a comment. - Not covered here: STATUS 4480 (on a feat app build, the killed terminal is in no tab after the reopen). The daemon-only kill_all tests on feat keep the terminal in its tab under the same id, so that defect is outside this daemon path. It is under investigation and gets its own fix.
Risk
risk:high: the change adds a product-code file delete (fs::remove_file of a tail file that a newer snapshot covers). It changes no setting. It fixes a data-loss defect, so it does not ship dark. terminal.persistence.mode = memory still turns all output files off.
Changelog
Fixed: after a crash that kills cmux and all its background processes, each terminal comes back with its last screen and scrollback (up to the last 0.1 s of output), and its shell stays in the same directory path (/tmp no longer becomes /private/tmp).