Skip to content

fix(remote): close the row gaps at the join in a remote history load - #609

Open
timkjr wants to merge 5 commits into
Ark0N:masterfrom
timkjr:fix/remote-history-screen-splice
Open

timkjr wants to merge 5 commits into
Ark0N:masterfrom
timkjr:fix/remote-history-screen-splice

Conversation

@timkjr

@timkjr timkjr commented Oct 11, 2026 •

Copy link
Copy Markdown

Stacked on #602: the first two commits are #602's; this PR is the last three (28b41efa, 8e2594a2, 8d92e577). Once #602 lands it reduces to those.

Summary

#602 loads a remote session's scrollback from the remote tmux and puts it above the local frame. Its docs record two seams where rows can drop out of the join:

  • Rows that scroll during the round trip. The local frame is read only after the remote read returns, so rows that scrolled off the screen in between are in neither.
  • A shared remote session with a taller client. Under window-size latest the remote screen can be taller than this pane, and the rows above this pane's frame exist only on the remote screen.

This PR closes both, and a third gap found while testing it end to end.

1. Keep the remote screen rows above the local frame (28b41efa)

The remote script also captures the remote SCREEN in the same round trip. joinScrollbackAndFrame() places the local frame among the screen rows (findFrameInScreen()) and keeps the screen rows above it between the history and the frame:

  • The match is on text, with escape sequences and trailing blanks ignored, since the two tmuxes need not encode attributes alike.
  • The screen's trailing blank rows and its last content row (which output in flight can still change) are left out of the match.
  • The overlap must be at least 3 rows, 2 of them non-blank. A placement too low would repeat rows, so a shorter overlap is not trusted.
  • The highest match wins.
  • When the frame cannot be placed, the join is history over frame, as in fix(remote): load a remote session's scrollback from the remote tmux #602. That includes an agent TUI mid-turn (Claude Code, codex, opencode), whose live region pinned to the bottom keeps the frame from being a shifted copy of the screen; a plain shell places.

2. Read the history and the screen atomically (8e2594a2)

An end-to-end run (below) showed the first commit still dropped 1-2 rows per load while output streamed. The cause was in the remote script: the history and the screen were two separate tmux runs, and rows that scrolled between them in those milliseconds were in neither capture. A remote read showed this directly: the history ended at row N and the screen started at N+2.

The history capture, the history size and the screen capture are now ONE tmux command list:

printf '\0'; tmux -L <socket> capture-pane -p -e -J -S -<N> -E -1 -t =<session>: \; display-message -p -t … <marker> \; display-message -p -t … '#{history_size}' \; capture-pane -p -e -J -t … | tail -c <cap>; printf '\0'
  • tmux output cannot carry the NUL markers, so a random marker row (codeman-history-<16 hex>, new per read) separates the history from the size and screen. tmux before 3.2 prints no newline after capture-pane -p, so when the history ends inside a wrapped line the marker is glued onto that row; the parser accepts the marker at the end of a row and re-terminates the history (8d92e577).
  • The tail -c bound now covers the whole tmux output, history first. It is the configured byte cap plus 1 MiB for the marker, size and screen (remoteTailBytes()), so a small cap cannot cut into the screen and a cut lands in the history, whose partial first row is dropped as before. The route already trims what it returns to the cap, keeping the newest bytes.
  • The two NULs are printed whether or not tmux succeeds. Both present, nothing usable between them, means the script ran and the session is gone, so there is no host back-off. This is the same split fix(remote): load a remote session's scrollback from the remote tmux #602 uses for the masked exit status.

Decision for the maintainer: this relies on tmux's command-queue behaviour

Atomicity here rests on how tmux runs a client's command list: it runs the commands back to back without reading pane output in between. That behaviour is measured below, but tmux does not document it as a guarantee. If a future tmux yields between commands in a list, the gap comes back. It would be 1-2 rows under streaming output, and nothing worse than #602 alone.

Measured on a local tmux with an unthrottled output loop, 200 reads each:

reads with a gap at the history/screen join gap size
separate tmux runs 200 / 200 342-863 rows
one command list 0 / 200 —

Alternatives considered:

  • One capture-pane -S -<N> through the bottom of the screen, split by #{pane_height}. It is atomic by construction. But -J joins wrapped rows, so the screen's row count in the output is not pane_height, and the split is inexact exactly when lines wrap. The join then needs the frame matched against the whole capture rather than the screen.
  • Screen first, then history, deduplicated by an overlap match. The race becomes duplicates instead of gaps, but output that really repeats the same rows (a progress line, a separator) could lose real rows as "duplicates".
  • Not taking the splice at all. On a LAN host the round-trip seam is small (below), and fix(remote): load a remote session's scrollback from the remote tmux #602 alone loses only a few rows per load while output streams, and none once it stops.

The command list was chosen because it keeps the exact history/screen boundary and the existing join. Whether depending on tmux's queue behaviour is acceptable, or one of the alternatives fits better, is the maintainer's call; the PR can be reworked to any of them.

End-to-end test (beta instance, real remote host on the LAN)

Remote shell on a Proxmox host over ssh, about 170 ms per load. Each load is GET /api/sessions/:id/terminal?full=1&tail=…&lines=10000, and the rows are numbered so gaps and duplicates can be counted.

#602 (136f1154) + screen splice + one command list
5000 lines, output finished — 1-5000, no gaps 1-5000, no gaps
8 loads while output streams (~180 rows/s) 2-3 rows missing per load 1-2 rows missing per load no gaps, no duplicates

With unthrottled output, one load lost 2,502 rows: the output outran the 40-row screen during the round trip, so the frame had nothing to match and the join fell back to history over frame. That is the documented no-match case, and the same as #602.

Not tested live: a remote screen taller than the local pane (the beta session takes the local pane's size). That path is covered by unit tests only.

Still open

  • When the screen repeats a whole block of rows, the frame can be placed at the higher copy, which leaves out the rows between the two copies (documented).

Tests

  • test/remote-pane-history.test.ts:
    • The script runs under a real /bin/sh with a stub tmux that executes a ; command list. It checks there is ONE tmux run, the exact argv in order, a tail -c cut dropping the partial row, and both NULs printed when tmux fails.
    • Parser cases: banner before the script, empty history, a cut history, a cut landing exactly on the marker row, the pre-3.2 shape with the marker glued onto an unterminated row (plain, cut, and with no history), another read's marker rejected, a missing closing NUL. The real-shell stub also prints the pre-3.2 shape.
    • remoteTailBytes() adds the screen allowance and keeps 0 unbounded.
    • Join cases: an overlap during the round trip, a taller screen, an in-flight last row, an attribute-encoding difference, a blank or too-short overlap, a repeated block.
    • The fetch tests answer with the marker found in the command.
  • test/routes/session-routes.test.ts: the route passes the screen through.
  • Gates: typecheck, lint, format:check, check:frontend-syntax, check:browser-excludes, npm test (10707 passed, 12 skipped, 0 failed).

🤖 Generated with Claude Code

timkjr and others added 4 commits October 10, 2026 12:49
A remote SSH session's local pane runs `ssh -t … tmux new-session -A` (or
`attach`), so the local tmux only sees the remote tmux client's full-screen
repaints and keeps a fraction of the history: measured 48 of 279 lines while
output streamed, none after a reattach. The remote pane, where the shell
runs, keeps all of it.

A full-history load (`GET /api/sessions/:id/terminal?full=1`) of a remote
session now reads the scrollback rows from the remote pane in one ssh round
trip (`#{history_size}` + `capture-pane -S -<N> -E -1`, exact `=name:`
target, newest bytes kept by a remote `tail -c`), and the local capture
reads only its visible frame (`-S 0`), spliced below them
(`PaneCaptureOptions.scrollbackOverride`). The local frame and caret are
kept because the live stream keeps drawing into the local pane.

Fail-soft and bounded: connection options from buildSshConnectionArgs(),
the shared remote-ssh limiter (a slot waited for longer than 1.5 s is given
up), a 5 s timeout, and a 30 s per-host back-off after any failure, which
keeps the local capture as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…f only on transport failures

Review follow-ups for the remote scrollback read.

The 1.5 s slot-wait bound was checked inside the task, so it only fired
once runWithRemoteSshLimit() had already handed over a slot, after
everything queued ahead of it. runWithRemoteSshLimit() now takes
`maxWaitMs`: a waiter that times out leaves the queue, rejects with
RemoteSshSlotTimeoutError, never runs and never touches `active`. The
history read uses it, and a slot timeout does not back the host off.

Only a transport failure backs the host off now (ssh exit 255, a
timeout, a spawn error, output that is not the script's), via
classifyRemoteAliveExit(). A remote command failing is about that
session only. A live run against a real host showed a missing session
comes back as exit 0 with empty markers, because the `| tail -c` pipe
takes tail's exit status, so markers that arrived without a size are
also per-session.

fetchRemoteScrollback() takes an optional runner as a test seam; tests
now cover the bounded wait, each back-off class, its expiry, and the
masked missing-session case. A source pin covers the `-S 0` capture
branch. The two seams that can drop rows at the join (rows that scroll
during the round trip, a taller shared client) are documented in
docs/remote-sessions.md, and the limiter's header and the remote-ssh
invariants now name the history read as an ssh consumer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…emote history load

The remote history and the local frame are read a round trip apart, so
two seams dropped rows at the join: rows that scrolled off the screen
during the round trip, and the top of a remote screen taller than this
pane (a shared session sized by a taller client under window-size
latest). Both are rows of the remote screen above where the local frame
begins.

The remote script now also captures the screen, right after the
history, in the same round trip. joinScrollbackAndFrame() places the
local frame among the screen rows (findFrameInScreen(): text compare
with escape sequences and trailing blanks ignored, the screen's trailing
blank rows and last content row left out, at least three rows of
overlap with two of them non-blank, highest match wins) and keeps the
screen rows above it between the history and the frame. When the frame
cannot be placed, the join is history over frame as before. The read's
buffer slack grows to 1 MiB, since the screen is outside the tail -c
bound.

Left: the history and screen are two tmux commands, so a row that
scrolls between them (milliseconds) is lost, and a screen that repeats
a whole block can place the frame at the higher copy, losing the rows
between the copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The remote script ran the history capture, the history size and the
screen capture as separate tmux runs, so the shell kept writing between
them and rows that scrolled in those milliseconds were in neither
capture. Measured end to end against a LAN host with output streaming:
1-2 rows lost per load at the history/screen join (the history ended at
row N, the screen started at N+2); on a local busy pane, every read.

The three reads are now one tmux command list (capture \; display \;
display \; capture). tmux runs a client's command list back to back
without reading pane output in between, so the screen begins where the
history ends: 0 gaps in 200 reads of a local busy pane, and no gaps or
duplicates in 8 loads against the LAN host. This rests on tmux's
command-queue behaviour, not a documented guarantee.

tmux output cannot carry NULs, so a random marker row per read
(display-message) separates the history from the size and the screen;
the parser takes it only as a whole row. The NULs around the tmux output
are printed whether or not tmux succeeds, so a missing session still
reads as the script having run (no host back-off), with its own log
text when tmux printed something unparseable. The remote tail -c bound
now covers the whole output, history first, and is the configured cap
plus 1 MiB for the marker, size and screen (remoteTailBytes), so a cut
lands in the history; the route trims to the cap itself. A cut landing
exactly on the marker row keeps it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Ark0N

Ark0N commented Oct 11, 2026

Copy link
Copy Markdown
Owner

Thanks @timkjr for this follow-up to #602. It closes the row gaps at the join in a remote history load: the remote screen is read in the same round trip, the screen rows above the local frame are spliced in, and the history, size and screen come from one tmux command list so nothing scrolls between them.

On the design question you raised: the command list is the right call, no rework needed. I reproduced it on tmux 3.4 with an unthrottled output loop: one command list gave 0 of 100 reads with a gap at the history/screen boundary, separate tmux runs gave 100 of 100. If a future tmux ever yields between commands, the cost is #602's behaviour, not a failure.

Two things before merge:

1. tmux older than 3.2 breaks the marker parsing (src/remote-pane-history.ts:197)

capture-pane -J leaves a soft-wrapped row unterminated. tmux 3.2 and newer always print a trailing \n after capture-pane -p output, so on 3.4 your script and parser handle it (I checked this against a real pane). tmux 3.1c, 3.0a and 2.7 do not add that newline (see their cmd-capture-pane.c). When the history/screen boundary falls inside a long wrapped line, the marker gets glued onto the last history row:

\0...\nfirst half of long row<marker>\n31\nsecond half\n$ \n\0

parseRemoteScrollbackOutput only accepts the marker at the start or after a \n, so it returns null, and the load falls back to the local capture (the partial history #602 replaces) on every load while the screen stays that way. #602 alone tolerated this, because its join re-terminated the history. The remote launch supports hosts older than 3.2 on purpose: remain-on-exit failed sits last in the launch chain for exactly that reason, and Debian 11, Ubuntu 20.04 and RHEL 8 ship 3.1c, 3.0a and 2.7.

Either fix works:

  • accept ${marker}\n at the end of an unterminated row too (the marker is random per read, so the pane cannot hold it by chance), then terminate the history with \n when it lacks one, or
  • print a separator row before the marker (for example an extra display-message -p '') and strip exactly one \n before the marker in the parser.

Please add a parser test with the pre-3.2 output shape. The "finds the marker only as a whole row" test (test/remote-pane-history.test.ts:149) will need adjusting if you take the first option.

2. Document that the splice does not engage for agent TUIs mid-turn (src/tmux-manager.ts:742, docs/remote-sessions.md:291)

findFrameInScreen compares every row down to the screen's last content row. Claude Code, codex and opencode keep a live region pinned to the bottom (spinner, composer, footer) and insert new rows above it, so the frame is not a shifted copy of the screen. With Claude-style rows and two new rows, it returns -1 even when the spinner did not change. A plain shell places correctly. Falling back to the #602 join is fine, but please say so in the docs next to "When the frame cannot be placed" and in the findFrameInScreen JSDoc. A prefix match for that case belongs in a separate PR.

Everything else checks out: typecheck, lint, format, the full npm test gate (10705 passed) and CI are green, the ssh line still goes through buildSshConnectionArgs(), and the parser edge cases are well covered.

Next steps: #602 lands first. Once it has, please rebase this branch onto master so it carries only your two commits, and include the two items above. Then it is ready to merge.

tmux before 3.2 prints no newline after `capture-pane -p` output, and
`-J` leaves the history's last row unterminated when the screen starts
inside a wrapped line. The marker row printed next is then glued onto
that row (`...first half<marker>\n31\n...`). The parser took the marker
only as a whole row, so it returned null and every load fell back to
the local capture while the screen stayed that way. Debian 11, Ubuntu
20.04 and RHEL 8 ship tmux 3.1c, 3.0a and 2.7.

The parser now takes the first `<marker>\n` wherever it ends a row (the
marker is random per read, so no row the pane holds ends with it) and
re-terminates a history that lacks its newline. A tail -c cut still
drops only the history's partial first row, now decided on the whole
output, so a cut inside the one row the marker is glued to leaves no
history instead of losing the marker. dropPartialFirstRow, no longer
called, is removed.

Docs and the findFrameInScreen JSDoc also say the splice does not place
the frame of an agent TUI mid-turn (a live region pinned to the bottom,
new rows inserted above it), which falls back to the history-over-frame
join.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@timkjr

timkjr commented Oct 12, 2026

Copy link
Copy Markdown
Author

Thanks for reproducing the command list on 3.4. Both items are in 8d92e57, on top of the two commits.

  1. I took the first option. The parser takes the first <marker>\n wherever it ends a row and adds the newline back to a history that lacks one. A tail -c cut still drops only the history's partial first row. That's now decided on the whole output, so a cut inside the one row the marker is glued to leaves no history and keeps the marker. Parser tests cover the pre-3.2 shape, plain and cut. The real-shell test runs a stub tmux that prints the history without its final newline. The "only as a whole row" test is replaced by those. dropPartialFirstRow wasn't called anymore, so it's gone.
  2. docs/remote-sessions.md (next to "When the frame cannot be placed") and the findFrameInScreen JSDoc now say an agent TUI mid-turn isn't placed and falls back to the history-over-frame join. A plain shell is placed. I'll leave the prefix match for a separate PR, as you suggested.

I'll rebase onto master once #602 has landed, so this carries only its own commits.

Typecheck, lint, format, frontend syntax, browser-excludes and the full npm test are green here (10,707 passed).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants