RFC: Persistent terminal sessions across Zed restarts #50584
dsturnbull
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Commit: 274595a / https://github.com/dsturnbull/zed/tree/pty-host
Problem
When Zed restarts (update, crash, or intentional quit), all terminal processes die. Zed directly owns the PTY master file descriptor; when the process exits, the kernel delivers SIGHUP to the shell's process group.
People work around this with tmux or zellij, but that introduces its own problems (discussed below).
Related discussions:
Background
The core problem is PTY ownership. The editor process holds the master side of the PTY; when it exits, the kernel tears down the slave side and the shell receives SIGHUP. The solution, in every case, is the same: move PTY ownership into a process that outlives the editor.
How tmux does it
tmux runs a long-lived server process that owns all PTYs. The tmux client attaches to the server over a Unix socket. When the client disconnects, the server keeps running and the shells stay alive. On reconnect, tmux re-renders the visible portion of the terminal from its internal screen buffer.
This works well as a standalone tool, but embedding it inside an integrated terminal creates friction:
Nested keybindings. tmux interposes its own key handling layer between the outer terminal emulator and the shell. The default prefix key (
Ctrl-B) steals a binding the user might want, and modifier pass-through (particularly forCtrl,Shift, and extended keys) requires careful configuration. Users who already have tmux keybindings tuned for a standalone terminal find them conflicting with the editor's own bindings.Scrollback behaviour. tmux maintains its own scrollback buffer, separate from the outer terminal's. Scrolling with the mouse wheel or keyboard in the integrated terminal scrolls the outer terminal's buffer, not tmux's. You need to enter tmux copy mode first, or configure mouse mode. This is confusing when scrollback content appears to be missing.
Double terminal emulation. The shell's output is parsed by tmux's built-in terminal emulator (based on a fork of the VTE state machine), which then re-emits escape sequences to the outer terminal emulator (in Zed's case, alacritty). In practice this works for most programmes, but edge cases exist: applications that use less common escape sequences, sixel/image protocols, or extended colour features can see rendering differences or dropped sequences. The performance overhead of double parsing is small (both are fast state machines), but the semantic gap is the real source of bugs: tmux decides what to forward and what to absorb, and gets it wrong in subtle ways.
Configuration burden. Getting tmux to behave transparently inside an integrated terminal requires non-trivial configuration. You can mitigate many of the above issues with something like:
Where
smcup@:rmcup@disables the alternate screen so scrollback works normally,clearreplaces the clear sequence with one that scrolls content rather than erasing it,kmous@disables tmux's mouse interception, andterminal-featuresopts in to clipboard integration, cursor colour/style changes, focus reporting, and title setting. But this is expertise the user shouldn't need, and it's fragile across tmux versions and shell combinations.Why terminal emulators haven't integrated tmux directly. iTerm2 is the notable exception: it has a
-CCcontrol mode that speaks a structured protocol to tmux, letting iTerm2 handle rendering while tmux handles persistence. But this is complex (iTerm2's tmux integration code is substantial), only works with iTerm2, and still has known issues with mouse reporting, extended keys, and image display. Alacritty, Ghostty, WezTerm, and other GPU-accelerated terminal emulators have generally avoided this path. Integrating tmux means either implementing the-CCprotocol or running tmux inside the terminal and accepting the double-emulation cost. Most of these projects consider session persistence out of scope for a terminal emulator, or recommend using tmux externally.How VS Code does it
VS Code moved PTY ownership to a separate pty-host process. The original motivation was stability and performance: node-pty crashes were taking down the entire renderer process, and fast-producing programmes could overwhelm the event loop (see vscode#74620 for the full discussion, and vscode#20013 for the original feature request for terminal persistence). The pty-host is a Node.js child process that owns the PTY and communicates with the renderer over IPC, with flow control to back-pressure fast producers.
On reconnect (e.g. after a window reload), VS Code replays buffered terminal output bytes back through xterm.js to reconstruct the display. This means the pty-host must buffer a window of raw output, and the client must re-parse all the escape sequences on reconnect. For a large scrollback buffer, this can take visible time.
VS Code's approach is tightly coupled to xterm.js and Node.js. Reusing it in Zed would mean either adding a Node.js dependency or reimplementing the replay protocol against alacritty's terminal emulator, neither of which is appealing.
Our approach
We took the same structural idea (move PTY ownership to an external daemon) but built it around Zed's existing terminal stack.
pty-hostis a small Rust daemon that owns the PTY and shell. One daemon per terminal session. Instead of replaying raw bytes, it runs a headlessalacritty_terminal::Termthat processes every byte from the PTY, maintaining a complete in-memory terminal grid. On reconnect, it serialises the fullTermState(both grid buffers, cursor, modes, scroll region) via bincode and sends it to the client in a single snapshot. No replay, no re-parsing.After sending the snapshot, the daemon sends
SIGWINCHto the foreground process group viatcgetpgrp/kill(-pgrp, SIGWINCH). This follows tmux's approach. The kernel only auto-delivers SIGWINCH when the terminal size actually changes viaTIOCSWINSZ, so on a same-size reconnect, full-screen applications like vim or htop wouldn't know to redraw. The explicit signal handles this.Because pty-host and Zed use the same
alacritty_terminalcrate at the same git rev,TermStateis literally the same type on both sides. This is the main payoff of writing a custom daemon rather than reusing VS Code's. The snapshot/restore code is straightforward:term.snapshot()on the daemon side,term.restore(state)on the client side. No serialisation format to version, no compatibility layer to maintain. The daemon is more code to write than "just use tmux", but the integration is clean.We extended the alacritty fork with
Term::snapshot()/Term::restore()and serde derives on the types in the serialisation chain (behind aserdefeature flag). The pty-host client implements alacritty'sEventedPty+OnResizetraits, so it slots into the existingEventLoopalongside the existing direct-PTY code path.Design
Components
pty-hostThe daemon is a standalone Rust binary, one process per terminal session. It allocates a PTY via
rustix-openptyand spawns the shell as a proper session leader (setsid,TIOCSCTTY). It then listens on a Unix domain socket for a single client connection at a time. If a new client connects, the previous one is evicted.Internally, the daemon runs a headless
alacritty_terminal::Terminstance that processes every byte read from the PTY master. This means the daemon always has an up-to-date copy of the terminal grid, scrollback, cursor position, and mode flags. When a new client connects, the daemon sends a bincode-serialisedTermStatesnapshot, followed by aReplayDonesentinel, then switches to live proxying. It also sendsSIGWINCHto the foreground process group so full-screen applications redraw.Flow control is handled via high/low water marks on the client write queue. When the queue exceeds 1 MiB, the daemon stops reading from the PTY master, causing the kernel's PTY buffer to fill, which naturally back-pressures the shell. Reads resume when the queue drops below 256 KiB.
The wire protocol is a simple framed binary format: a 1-byte type tag, a 4-byte little-endian length, and a payload. Client messages include
Data(user input),Resize,Detach, andKill. Host messages includeData(terminal output),Ready,Snapshot,ReplayDone,ChildExit, andError.alacritty_terminalforkZed already depends on a fork of alacritty's terminal emulator crate. We added
Term::snapshot()andTerm::restore(TermState)for capturing and restoring full terminal state, gated behind aserdefeature flag. This required adding#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]to every type in the serialisation chain:Grid<Cell>,Storage,Row,Cell,Flags,Line,Column,Point,Cursor.The
PtyHostClientstruct implements alacritty'sEventedReadWrite,EventedPty, andOnResizetraits, so it can be passed to alacritty'sEventLoop::new()in place of a real PTY file descriptor. Internally, the client runs a deframing thread that readsHostMessageframes from the socket and writes raw terminal output bytes into a pipe that theEventLoopreads from, keeping the framing protocol invisible to alacritty.Zed-side integration (
crates/terminal,crates/terminal_view,crates/project)The decision of whether to use pty-host is made in
project::terminals::create_terminal_shell_internal: local Unix interactive shells use pty-host; remote shells and task terminals keep the existing direct-PTY path, unchanged.TerminalBuilder::from_pty_host()handles connection, snapshot restoration, and wiring up theEventLoop. A newTerminalType::PtyHostedenum variant stores the session UUID, theNotifierchannel, and the socket path.The
Dropimpl forTerminalType::PtyHostedsendsClientMessage::Detachrather than killing the child process. This tells the daemon to keep running. The daemon exits on its own when the shell process exits (or after a grace period if the client hasn't read the exit status).TerminalViewserialisation writes the session UUID to SQLite. On deserialisation, it checks whether the socket file still exists, attemptsfrom_pty_host()again, and falls back to a fresh terminal if the daemon is gone.Scope and known limitations
SCM_RIGHTSwould be cleaner and avoid the TOCTOU race on the socket path. This could be added later without protocol changes.Open questions
Orphan cleanup. Daemons currently live until the shell exits or the user explicitly closes the tab (sends
Kill). Should there be a timeout for orphaned sessions (e.g. daemon still running but no Zed workspace references it)? A cap on total sessions?Task terminals. Tasks currently die with Zed. Should task terminals optionally use pty-host? A long-running
cargo watchsurviving a restart would be useful, but tasks also have different lifecycle expectations.Reconnect UX. Should there be any visual indication when a terminal reconnects vs. starts fresh? Currently it's silent.
Windows. ConPTY is fundamentally different from Unix PTYs. Is there interest in this on Windows, and what would the daemon look like?
Remote. For SSH remote terminals, running pty-host on the remote host would give persistence there too. Worth pursuing?
Should we just use tmux? A reasonable counter-argument is that tmux already solves this problem, and a lot of the friction described above can be mitigated with configuration. With the right
terminal-overridesandterminal-featuressettings (disabling alternate screen capture, forwarding clipboard/cursor/focus/title, suppressing mouse interception), tmux becomes fairly transparent. We could ship a bundled tmux configuration that applies these settings automatically, avoiding the user-facing configuration burden. The trade-offs would be: depending on tmux as an external binary (packaging, version skew), accepting the double-emulation layer, and giving up on pixel-perfect state restoration. But it would be significantly less code to maintain. Thoughts?Feedback welcome -- especially from people who currently use tmux/zellij as a workaround, or who run long-lived processes (dev servers, AI agents, etc.) in Zed's terminal.
Beta Was this translation helpful? Give feedback.
All reactions