Skip to content

Scrollback Snapshots & Cold Replay

dazeb edited this page Sep 17, 2026 · 2 revisions

Scrollback Snapshots & Cold Replay

The tmux server holds scrollback in memory. When a machine reboots (or the tmux server otherwise dies), every session's history is gone with it — reattaching to a session of the same name yields an empty pane. ScrollbackStore closes that gap: while a session is attached it periodically dumps the pane's text (with escape codes) to a per-node file under userData/terminal-scrollback/<nodeId>.txt, and on a cold start the terminal spawn path can read that file back and replay it into the fresh PTY before tmux output begins. Warm reattaches are expected to skip replay entirely, because tmux redraws the pane itself.

Runtime mechanics

Capture loop. start(nodeId, tmux) registers a 5-second interval and does nothing else — the absence of an immediate snapshot is deliberate (L28-L31): on a cold start the freshly created pane is empty, and writing immediately would overwrite the previous run's file, which is exactly the data the replay needs. First capture therefore always happens one interval after attach. start first calls stop(nodeId) without a tmux config, so re-arming a node discards the old timer without a final capture.

Capture command. Each tick runs tmux capture-pane -e -p -S -4000 -t <sessionNameFor(nodeId)> with the caller's tmuxPath and baseArgs, so the capture targets the same server/socket the attach flow uses. -e keeps SGR escapes (colors replay), -p writes to stdout, -S -4000 pulls history plus the visible screen. The exec is asynchronous with a 2 s timeout and a 512 KiB maxBuffer; it never blocks the caller, which matters because the first capture-pane after a tmux server spin-up can be slow.

Persist. On success the directory is re-created defensively, the payload is capped, and it is written as UTF-8. On any error the callback returns silently, leaving the last good snapshot in place. stop(nodeId, tmux) clears the timer and optionally takes one final snapshot; stopAll(tmux) snapshots every live timer key before clearing (iterating a copy, since stop mutates the map); destroy(nodeId) clears the timer without a final snapshot and deletes the file.

Cold vs. warm replay. The decision to replay lives at the call site, not in this store. The store exposes read(nodeId): string | null; a caller that knows it is on the cold path (tmux server absent) feeds that text into the terminal at local-PTY spawn, producing the history followed by the app's usual "session restored" separator. Callers that are reattaching warm simply never call read.

sequenceDiagram
    participant Spawn as Terminal spawn path
    participant SS as ScrollbackStore
    participant FS as userData/terminal-scrollback
    participant TM as tmux

    Note over TM: machine rebooted — server gone
    Spawn->>SS: read(nodeId)
    SS->>FS: readFileSync(nodeId.txt)
    FS-->>SS: prior run's tail (or null)
    SS-->>Spawn: text | null
    Spawn->>Spawn: replay into fresh xterm (cold path only)
    Spawn->>TM: create/attach sessionNameFor(nodeId)
    Spawn->>SS: start(nodeId, tmux)
    Note over SS: no snapshot until first tick — pane was empty at attach
    loop every 5s
        SS->>TM: capture-pane -e -p -S -4000 -t <name>
        TM-->>SS: pane text + escapes
        SS->>FS: cap to tail, write
    end
    Spawn->>SS: stop(nodeId, tmux)
    SS->>TM: final capture-pane
    SS->>FS: write
Loading

The diagram's critical asymmetry: read happens before the session is attached, and start happens after. That ordering is what makes the "no immediate snapshot" rule safe and what keeps a cold start from destroying its own replay input.

stateDiagram-v2
    [*] --> Absent
    Absent --> Seeded: importSnapshot() (space pull)
    Absent --> Live: start() then +5s
    Seeded --> Replayed: cold start read()
    Seeded --> Live: warm attach, start()
    Replayed --> Live: start()
    Live --> Live: every 5s, tail-capped write
    Live --> Stopped: stop() → final snapshot
    Stopped --> Live: start()
    Live --> Absent: destroy()
    Stopped --> Absent: destroy()
Loading

Key state and files

src/core/scrollback-store.ts is the whole feature. Its state is two things: dir (resolved once in the constructor from userDataPath, created synchronously) and timers, a Map<nodeId, Interval> that is also the set of "sessions currently being snapshotted". There is no in-memory copy of file contents — read always goes to disk, and any failure (missing file, permissions, directory removed) collapses to null.

The file name is ${nodeId}.txt, and the tmux target is sessionNameFor(nodeId) imported from ./tmux. That shared derivation is the load-bearing invariant: because both the file path and the tmux session name are functions of the same stable node id, a restart lands the replay on the terminal that produced the snapshot. Rename or regenerate a node id and the replay is orphaned.

importSnapshot(record) and readMany(ids) are the sync seams, used when scrollback travels with a cloud-space snapshot. importSnapshot accepts a nodeId → text record, skips non-string values, applies the same cap, and returns a count of entries written — a write failure is swallowed so an import never fails because of scrollback. readMany is the mirror: it returns only ids that actually have a file, so a terminal that never ran carries nothing rather than an empty string.

Boundaries and failure modes

  • The cap is in UTF-16 code units, not bytes. out.length and String.slice are used for the 256 KiB cap, so multi-byte content can produce a file several times larger than 256 KiB, and a surrogate pair can be split. Head-truncation can also cut an ANSI escape sequence in half, leaving the replayed stream starting with a partial SGR. Any consumer should treat the snapshot as a best-effort tail, not a well-formed escape stream from byte zero.
  • maxBuffer and timeout are silent drop conditions. A capture exceeding 512 KiB or taking more than 2 s produces err, and the callback keeps the previous file. Dense escape output or a first-time tmux server start can therefore skip a tick — or, at quit time, the final one.
  • stop/stopAll do not await the final snapshot. They fire an async execFile and return. A quit that exits the process promptly can lose the last few seconds of output; the 5 s interval bounds how much is at risk on a crash.
  • Late callbacks can resurrect a destroyed file. destroy clears the timer and rmSyncs, but a capture already in flight will still run its success callback, which re-creates the directory and writes the file. If the node id is reused, a stale replay is possible. Gating the write on a per-node generation counter would close this.
  • No orphan cleanup. Files are only removed by destroy, so nodes deleted through a path that does not call it leave <nodeId>.txt behind. readMany never surfaces them, so the cost is disk only.
  • readMany is N synchronous reads. Restoring a large workspace pays one blocking readFileSync per node on the main process.
  • Directory creation can throw in the constructor, unlike the callback paths which explicitly tolerate a missing directory.

Extension points

The tunables — SNAPSHOT_INTERVAL_MS, MAX_BYTES, CAPTURE_BACK_LINES (L15-L17) — are module constants, not settings; changing them changes replay depth, file size, and quit-time loss window together. The policy of when to replay (cold only vs. always) is intentionally outside this module; if you need to change it, change the spawn caller. importSnapshot is the ingestion seam for any other producer of snapshot text, and file layout is flat nodeId.txt, so any sibling metadata must avoid colliding with node ids. This page's excerpt does not include the terminal spawn path, the tmux reattach code, or the space-sync callers, so exact call sites and the replay injection implementation are not verifiable from these lines alone.

Sources: src/core/scrollback-store.ts, constants, lifecycle, snapshot, read/destroy, import/readMany, stopAll/fileFor

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally