mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
* feat(daemon): keep a pane's screen across a death nobody chose A daemon that crashes, is `kill -9`'d, or goes down with the machine takes every pane's replay ring with it, and the window comes back to a row of blank shells. The processes cannot be saved that way — nothing written to a file brings a process back — but the picture can. The daemon now keeps a capped tail of each pane's ring under the config directory, and a client whose `Attach` found nothing can ask, on the `Spawn` that replaces it, for the dead pane's screen. The new pane opens showing it, under a rule that says the shell below is new. - periodic and dirty-only: a ring that has not moved is not rewritten, so an idle machine does no IO at all. Write-through would be an enormous amount of write amplification for a few seconds of freshness. - capped at 256 KiB per pane, far below the ring's 8 MiB: the value of scrollback decays with distance from the bottom, and every byte here is a byte of someone's terminal on disk. - off by default. The ring holds whatever the pane printed, including echoed tokens, `env` output and agent transcripts; in memory that dies with the daemon, and writing it down is the whole feature and the whole cost. Files are 0600, and turning the setting off deletes what was kept. - dropped by relevance, not by calendar: a pane the user closed, or one no workspace names any more, has its file removed on the next sweep. Restored bytes are replayed at the geometry they were written at, and are preceded by resets — leave the alternate screen, show the cursor, restore autowrap, clear SGR — because a snapshot is cut at the front and can begin in the middle of any of them. * feat(daemon): upgrade the daemon in place instead of killing every shell Picking up a new build meant stopping the daemon, and stopping the daemon means every pane dies: the pty master is a descriptor this process holds, so when the process goes the slave side raises SIGHUP and takes the shell, the agent and the half-finished command with it. That is why the update path leaves the old daemon serving and Settings has to offer the restart as a thing you schedule for a quiet moment. `execve` does not have that problem. It replaces the image and keeps the process: same pid, same children, same descriptors, same file locks. The daemon now rewrites itself that way on `ClientMsg::Handoff` — it writes what it knows about each pane into a blob, clears FD_CLOEXEC on the pty masters, the blob and the singleton lock, and execs the new binary, which picks the panes back up on the other side. - **the seat travels on the command line, not in the blob.** The lock is still held by this process, so the new image must adopt the descriptor rather than ask for the lock again — asking would be refused by its own lock and it would stand down in favour of itself. A daemon that loses its panes is a bad afternoon; a daemon that exits leaves the machine with nothing serving, so that one fact has to survive an unreadable blob. - **the blob is unlinked before it is written.** It holds every pane's ring, which is the output `scrollback` makes people opt into storing; a handoff must not be a back door for writing it to disk. - **the exec is the last step.** Everything is staged first, so any failure before it costs a log line and the daemon carries on serving — which is what lets callers treat a failed handoff as "fall back to a restart" without having lost anything on the way. Native SSH panes cannot cross — their session is cipher state in memory, not a descriptor — so they are hung up first and the far end sees a clean close. Windows has neither execve nor a transferable ConPTY handle, so it keeps the stop/start path; the dialogs there still promise what they always did, and the new copy is shown only where it is true. Also retries flock on EINTR: a signal landing mid-call said nothing about the lock, but was reported as "could not be evaluated", which starts a second daemon beside the first — the split machine singleton exists to prevent. The end-to-end test sets a variable in the shell, hands over, and reads it back. Nothing but the original process can answer that, and the daemon's instance id changing while its pid does not is what says an exec really happened. * feat(shell): give each pane its own history when asked Two panes running zsh with `share_history` are appending to one file and reading each other's lines back, which is either the feature or the problem depending on what the panes are for. Someone with a pane per task wants Up to walk that task's commands, not an interleaving of four. Each pane can now have its own history file instead. It is seeded from the shell's real history, so a new pane is not blank, and what the pane added is appended back when it closes, so nothing typed is lost — a per-pane history that evaporated would be a way of losing commands, not of organising them. The seeding is done by the shell, not the daemon, and that is the only reason it works: `HISTFILE` belongs to the user's rc file and can point anywhere, long after the pane's environment was decided. tty7's snippet is appended to the rc it wraps, so it runs after that decision and is the one place the real path is known — it copies the tail, records how much it copied, and repoints. Both shells load history after their startup files, so the switch lands before the first line is read. The daemon's half is a filename, a rename when a restored pane inherits its predecessor's file, a merge on close, and a sweep for the panes a killed daemon never got to retire. Off by default: shared history is what a terminal has always done, and someone who did not ask for the change would experience it as their history mysteriously forgetting the other window. bash and zsh only — fish and PowerShell do not keep a HISTFILE, and a shell launched with the user's own arguments gets no snippet to repoint anything in. * fix(daemon): store pane screens on the shutdown a restart actually uses The periodic writer covers a death nobody prepares for and the SIGTERM path covers a signal, but the restart the app itself performs goes through ClientMsg::Shutdown — which killed every pty without taking a copy first. That is the one shutdown where the panes are expected back. * fix(daemon): leave nothing dangerous behind when a handoff fails or lands Review findings on the in-place upgrade and per-pane history: - A failed exec now puts back everything it had staged: FD_CLOEXEC on the seat and every pty master (a child inheriting the seat keeps the flock held past the daemon's death, so no future daemon could seat itself), and the SIGPIPE disposition plus this thread's signal mask, both of which Command::exec resets on its way to the attempt — without this, the still-serving daemon dies on the first client that hangs up mid-write. - The adopting image restores close-on-exec on the seat and on every adopted master, so children it spawns later cannot hold a pty open past its pane, or the seat past the daemon. - The target binary is checked before the handoff gives anything up: native-SSH panes are hung up on the promise that this process is about to be replaced, and an exec that was never going to work must not collect on it. - The integration snippets raise HISTSIZE/HISTFILESIZE (bash) and SAVEHIST/HISTSIZE (zsh) for the pane's private history file. At their defaults the exit rewrite truncates the file below its own seed mark, which the merge-back rightly reads as "replaced under us" — silently losing the pane's commands for anyone with more history than the caps. - The restart dialog's promise now binds the action: where the copy said "nothing is interrupted", a failed handoff is reported instead of silently traded for the restart that kills every pane. - The scrollback writer checks the ring's mark before cloning it, so an idle pane no longer costs a full ring copy under the state lock every tick. Each behavioural fix carries a test that fails without it; the history truncation one was verified to fail with the snippet change removed. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>