cmux-tui: terminal output leaves the session journal for a daemon-owned output store (memory / snapshot / full)

Merged
LA
15d ago
+2277-604

Summary

Terminal output no longer goes into the durable session journal, in any mode. Every PTY read was one terminal.output row (about 1.2 KB of metadata, six index rows and a synced commit). Three idle agent TUIs that redraw their cursor at 40 Hz made the daemon write about 25 MB/s on macOS, and Lawrence's registry reached 41.6 GB.

A new daemon-owned output store (cmux-tui-core/src/output_store*.rs) owns output now:

  • Hot copy: the daemon's Ghostty terminal (screen and scrollback, capped per terminal by Ghostty's scrollback-limit; cmux adds no per-terminal key). The store keeps each terminal's stream offsets and a 64 KiB raw tail in RAM. The PTY reader only copies bytes into memory. One background writer does all disk work, and it wakes only after output.
  • terminal.persistence.mode (cmux-next.json; CMUX_TUI_TERMINAL_PERSISTENCE overrides it):
    • memory: nothing on disk. Exit replays and close archives keep no screen, and a respawn takes no earlier screen from disk.
    • snapshot (default): one gzip VT replay per terminal (the screen plus recent scrollback), replaced atomically. It is written only when the text, size, screen or history changed, at most every 30 s, at a journal checkpoint and at quit. Total cap 50 MB, oldest first.
    • full: also gzip segment frames per terminal every 3 s (no fsync per write). Bytes of unchanged content wait in RAM (up to 1 MiB) until the next change, so files hold every byte. Caps: 100 MB per terminal and 2 GB total, oldest segment first. terminal.output_read pages old bytes in from the segments, and a respawn rebuilds the long scrollback from them.
    • The caps are terminal.persistence.maxBytesPerTerminal and terminal.persistence.maxTotalBytes.
  • terminal.scrollback.maxTotalMemoryBytes (default 256 MiB): past the total, the terminals that printed least recently get a lower Ghostty scrollback limit first.
  • Shipped builds cannot journal output: the TerminalOutput ingress variant, its writer arm and #22811's coalescer are #[cfg(test)]. terminal.output.gap stays as a small structural marker (host_reconnect, host_respawn) with no bytes.

Consumers moved:

  • terminal.output_read now reads the store.
  • Respawn seeding uses the exit replay, then the store's screen, then the checkpoint blobs of older registries.
  • Exit-snapshot coverage comes from the store.
  • Journal checkpoints keep no VT blobs and flush the store instead.
  • clear-history drops the stored output and rewrites the snapshot at once.
  • A new hook with the terminal_output regex field is refused.

Old registries keep their output rows; the readers still understand them. A separate lane (feat-cmux-next-output-cleanup) does the one-time cleanup of the old rows.

Settings schema rows: separate Swift change on feat-cmux-next-output-settings.

Measurements (rbx Linux, real daemon, CMUX_OUTPUT_STORE_IDLE_SECS=60)

base 52a1a09ethis branch
3 idle redrawing terminals, 60 s: daemon write() bytes783.7 MB (13.1 MB/s)0 in memory, snapshot and full
same: daemon CPU3.73 s (6.2% of a core)0.24-0.29 s (0.4-0.5%)
files changed in the state folder while idleregistry and WALnone
daemon RSS per terminal, 10 terminals x 10k lines (120 cols)12.4 MiB10.9 (memory, snapshot), 11.6 (full)
state folder after the 10k-line test13.1 MB5.4 MB

Behavior tests (real daemon, socket and files)

cmux-tui/tests/terminal_host_recovery/output_store.rs (red on the base: 13 MB/s of writes, output rows in the journal):

  • idle redrawing terminals change no file and leave no output row in each mode;
  • after a crash and a reboot (SIGKILL of the daemon, the shell and its host), the screen comes back in snapshot and full mode, and output_read from offset 0 returns the first line in full mode only;
  • memory mode leaves no file;
  • past the RAM budget, the idle terminals shrink first.

Updated: short_lived_resource_terminal_keeps_its_output_out_of_the_journal, output_coalescing (no output records now).

Observed / Not run

  • Ran on rbx: clippy -D warnings for ghostty-vt, cmux-tui-core and cmux-tui; the 7 output_store tests (twice, on 10 s and 60 s windows); terminal_respawn, host_supervisor, reconnect_checkpoints, output_read and the journal unit tests.
  • The full test run of the 8 affected crates found 8 failures that do not fail on the base. Fixes for 4 are in this branch, and 4 were runner flakes; all 8 pass in a focused re-run. The other failures are the same on the base (acpmux-dependent tests).
  • Gate receipt: see the landing comment.
  • Not run: macOS Swift app dogfood, and the case where full mode keeps RAM to a hot tail and pages scrollback history in on scroll (not built yet; see the follow-ups).

Changelog

Changed: terminal output is no longer stored in the session journal. terminal.persistence.mode (memory, snapshot, full) selects what stays on disk, and idle terminals no longer write to disk.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Terminal output no longer goes into the durable session journal. Every PTY read used to become a terminal.output row with a synced commit, which let idle redrawing TUIs write about 25 MB/s and grew one registry to 41.6 GB; now output lives in a daemon-owned output store and idle terminals write nothing.

The PTY reader only copies bytes into memory. One background writer does all disk work and wakes only after output. terminal.persistence.mode selects what stays on disk:

  • memory: nothing on disk; exit replays and archives keep no screen, and a respawn takes no screen from disk.
  • snapshot (default): one atomic gzip VT replay per terminal, written only when content changed, at most every 30 s, at checkpoints and at quit.
  • full: snapshot plus per-terminal compressed segment files every 3 s that hold every byte; output reads page old bytes back from them.

A new total RAM budget (terminal.scrollback.maxTotalMemoryBytes, default 256 MiB) shrinks the scrollback of the least recently printed terminals first. Consumers moved with it: terminal.output_read, respawn seeding, exit-snapshot coverage, journal checkpoints, and clear-history now use the store. A new hook with the terminal_output regex field is refused. Journaled output is test-only in shipped builds, and old registries keep their output rows, which readers still understand.