From 28b41efa58115507e7de064dfcf4291e470bddf3 Mon Sep 17 00:00:00 2001 From: timkjr Date: Sat, 10 Oct 2026 23:32:04 -0500 Subject: [PATCH 1/3] fix(remote): keep the remote screen rows above the local frame in a remote 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 --- docs/remote-sessions.md | 34 +++++--- src/mux-interface.ts | 8 ++ src/remote-pane-history.ts | 55 +++++++++---- src/tmux-manager.ts | 80 ++++++++++++++++++- src/web/routes/session-routes.ts | 5 +- test/remote-pane-history.test.ts | 121 ++++++++++++++++++++++++++--- test/routes/session-routes.test.ts | 6 +- 7 files changed, 267 insertions(+), 42 deletions(-) diff --git a/docs/remote-sessions.md b/docs/remote-sessions.md index 01c7428b..a592be7f 100644 --- a/docs/remote-sessions.md +++ b/docs/remote-sessions.md @@ -266,18 +266,34 @@ command itself (a remote tmux session that is gone) affects that session only. How much history exists is the remote tmux's own `history-limit`, which the launch command does not set (tmux's default is 2000 lines). -Two seams can drop rows at the join, silently. Neither loses anything that -loaded before this existed: +The same round trip also reads the remote SCREEN, right after the history, +because two seams would otherwise drop rows at the join: -- **Rows that scroll during the round trip.** The remote history is read - first and the local frame after it, so rows that scrolled off the remote - screen in between are in neither (measured: 13 lines lost with a 150 ms - round trip and fast output). +- **Rows that scroll during the round trip.** The local frame is captured only + once the round trip is back, so rows that scrolled off the screen in between + are in neither the remote history nor the local frame (measured before this + read existed: 13 lines lost with a 150 ms round trip and fast output). - **A shared remote session with a taller client.** Under `window-size latest` a remote session attached elsewhere is sized by its latest client; when that - client is taller than this pane, the rows between this pane's frame and the - remote screen's top are in neither the remote history nor the local frame - (measured: 20 rows). + client is taller than this pane, the rows above this pane's frame are only + on the remote screen (measured before: 20 rows). + +Both are rows of the remote screen above where the local frame begins, so the +load keeps them between the history and the frame (`joinScrollbackAndFrame`, +`findFrameInScreen` in `src/tmux-manager.ts`). The frame is placed by matching +its top rows against the screen as text (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 three rows +with two of them non-blank, and the highest match wins. A placement too low +would REPEAT the rows between it and the true one, so a shorter overlap (a lone +`$` row that happens to match after a spinner row changed) is not trusted. +When the frame cannot be placed (more output scrolled than the screen held, or +nothing matches), the join is history over frame, as without the screen read. What is left: the history and screen captures are two +tmux commands, so a row that scrolls between them (milliseconds apart, not the +round trip) is in neither, and 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. ## File access over SSH diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 9dcd2a1e..b2a8b5fc 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -196,6 +196,14 @@ export interface PaneCaptureOptions { * session's real history lives in the REMOTE tmux (`remote-pane-history.ts`). */ scrollbackOverride?: string; + /** + * With `scrollbackOverride`: the screen rows of the pane that history came + * from, read right after it. The frame this pane captures later can have + * scrolled past some of them, or show only part of a taller screen, so the + * rows above where the frame's top overlaps them are kept between the + * scrollback and the frame (`joinScrollbackAndFrame`). + */ + scrollbackOverrideScreen?: string; /** * Byte cap the consumer will keep from the capture. Sizes the child-process * stdout buffer (with slack) so multi-MB scrollback dumps aren't killed by diff --git a/src/remote-pane-history.ts b/src/remote-pane-history.ts index c2f5b44f..91f30ade 100644 --- a/src/remote-pane-history.ts +++ b/src/remote-pane-history.ts @@ -11,9 +11,14 @@ * the local pane is what the live stream keeps drawing into, so its frame and caret * position are the ones the browser must end up with. * - * ONE ssh round trip prints the remote pane's `#{history_size}` and its scrollback - * rows (`capture-pane -S - -E -1`). The size is load-bearing: with no history at - * all, tmux clamps `-E -1` to the first VISIBLE row and would return it as history. + * ONE ssh round trip prints the remote pane's `#{history_size}`, its scrollback + * rows (`capture-pane -S - -E -1`) and then its screen. The size is load-bearing: + * with no history at all, tmux clamps `-E -1` to the first VISIBLE row and would + * return it as history. The screen closes the seam between the two captures: the + * local frame is read only after the round trip, so rows that scrolled in between, + * and the top of a remote screen taller than the local pane, are on that screen + * alone, and the caller keeps the screen rows above where the local frame overlaps + * it (`joinScrollbackAndFrame`). * * Bounded and fail-soft. Any failure (unreachable host, auth, missing session, odd * output) returns null and the caller keeps the local capture, so a remote load is @@ -53,8 +58,12 @@ const REMOTE_HISTORY_SLOT_WAIT_MS = 1_500; /** After a failed read, skip that host for this long and use the local capture. */ const REMOTE_HISTORY_BACKOFF_MS = 30_000; -/** Headroom over the remote `tail -c` bound for the size line and markers. */ -const REMOTE_HISTORY_SLACK_BYTES = 64 * 1024; +/** + * Headroom over the remote `tail -c` bound for the size line, the markers and + * the screen capture, which the bound does not cover (a wide, tall screen of + * colored rows runs to a few hundred KB). + */ +const REMOTE_HISTORY_SLACK_BYTES = 1024 * 1024; /** Read ceiling when the configured byte cap is 0 (unbounded) and no `tail -c` runs. */ const UNBOUNDED_READ_MAX_BYTES = 64 * 1024 * 1024; @@ -86,9 +95,13 @@ export function buildRemoteScrollbackScript( // the remote side (`tail -c`) rather than by killing the read at maxBuffer. // A cap of 0 means unbounded, as in the route. const bound = maxBytes > 0 ? ` | tail -c ${Math.trunc(maxBytes)}` : ''; + // The screen comes right after the history, in the same round trip: the + // local frame is captured only once this returns, so rows that scroll off + // in between are on this screen and nowhere else (joinScrollbackAndFrame). return ( `printf '\\0' && ${tmux} display-message -p -t ${target} '#{history_size}' && printf '\\0' && ` + - `${tmux} capture-pane -p -e -J -S -${lines} -E -1 -t ${target}${bound}` + `${tmux} capture-pane -p -e -J -S -${lines} -E -1 -t ${target}${bound} && printf '\\0' && ` + + `${tmux} capture-pane -p -e -J -t ${target}` ); } @@ -115,19 +128,31 @@ export function dropPartialFirstRow(scrollback: string, maxBytes: number): strin return newline === -1 ? '' : scrollback.slice(newline + 1); } +/** What one round trip read from the remote pane: its scrollback rows, then its screen. */ +export interface RemoteScrollback { + /** History rows above the screen, oldest first; '' when the pane has none. */ + scrollback: string; + /** The screen's rows, read right after the scrollback. */ + screen: string; +} + /** - * The scrollback rows from the script's stdout, '' when the pane has no history, - * or null when the output is not the script's. + * The scrollback and screen rows from the script's stdout, or null when the + * output is not the script's. */ -export function parseRemoteScrollbackOutput(stdout: string): string | null { +export function parseRemoteScrollbackOutput(stdout: string): RemoteScrollback | null { const start = stdout.indexOf('\0'); if (start === -1) return null; const sizeEnd = stdout.indexOf('\0', start + 1); if (sizeEnd === -1) return null; + const historyEnd = stdout.indexOf('\0', sizeEnd + 1); + if (historyEnd === -1) return null; const sizeText = stdout.slice(start + 1, sizeEnd).trim(); if (!/^\d+$/.test(sizeText)) return null; - if (Number(sizeText) === 0) return ''; - return stdout.slice(sizeEnd + 1); + const screen = stdout.slice(historyEnd + 1); + // With no history at all, tmux clamps `-E -1` to the first visible row. + if (Number(sizeText) === 0) return { scrollback: '', screen }; + return { scrollback: stdout.slice(sizeEnd + 1, historyEnd), screen }; } /** Runs the ssh command line and resolves its stdout; rejects like `exec` does. */ @@ -157,7 +182,7 @@ export async function fetchRemoteScrollback( historyLines: number, maxBytes: number, run?: RemoteScrollbackRunner -): Promise { +): Promise { if (!run && process.env.VITEST) return null; const runner = run ?? execRunner; const until = backoffUntil.get(remote.hostId); @@ -177,8 +202,8 @@ export async function fetchRemoteScrollback( // is the better answer than waiting behind them. { maxWaitMs: REMOTE_HISTORY_SLOT_WAIT_MS } ); - const scrollback = parseRemoteScrollbackOutput(stdout); - if (scrollback === null) { + const parsed = parseRemoteScrollbackOutput(stdout); + if (parsed === null) { // Both markers arrived, so the script ran on the host and only tmux had // nothing to say: no such session. The `| tail -c` pipe hides tmux's // exit status, so this comes back as exit 0 rather than exit 1, and it @@ -190,7 +215,7 @@ export async function fetchRemoteScrollback( console.warn(`[remote-history] ${remote.label}: ${what}; using the local capture`); return null; } - return dropPartialFirstRow(scrollback, maxBytes); + return { scrollback: dropPartialFirstRow(parsed.scrollback, maxBytes), screen: parsed.screen }; } catch (err) { // A busy pool says nothing about the host. if (err instanceof RemoteSshSlotTimeoutError) return null; diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 548a9e45..e409be7b 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -694,14 +694,85 @@ export function normalizeScrollbackEol(buffer: string): string { return buffer.replace(/\r?\n/g, '\r\n'); } +/** `capture-pane -p` text as rows: tmux ends it with one `\n`, and '' has none. */ +function captureRows(text: string): string[] { + return text === '' ? [] : text.replace(/\n$/, '').split('\n'); +} + +/** + * Least overlap that places a frame in a screen: rows compared, and how many + * of them hold text. A placement too LOW repeats the rows between the true one + * and it, so a short overlap (one `$` or `❯` row, a rule line) is not trusted: + * past this, the plain join, which can only lose rows, is the safer answer. + */ +const FRAME_MATCH_MIN_ROWS = 3; +const FRAME_MATCH_MIN_CONTENT_ROWS = 2; + +/** + * A captured row as plain text: escape sequences (SGR, OSC 8 links) and + * trailing blanks dropped. Two tmuxes render the same cell content, but need + * not encode its attributes the same way. + */ +function capturedRowText(row: string): string { + // eslint-disable-next-line no-control-regex + return row.replace(/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]/g, '').trimEnd(); +} + +/** + * Where a later frame of the same pane begins among the rows of an earlier + * screen capture, or -1 when that cannot be told. + * + * The frame's top rows must equal the screen's rows from that index on, as + * text. Two rows are left out of the comparison: the screen's trailing blank + * rows (output after the screen read fills them in the frame) and its last + * content row (output in flight may have finished it since). Every compared + * row must match, the overlap must reach FRAME_MATCH_MIN_ROWS rows with + * FRAME_MATCH_MIN_CONTENT_ROWS of them non-blank, and the SMALLEST index wins, + * so a block that merely repeats further down is only chosen when nothing + * above it matches. A row that changed between the two reads (a spinner, a + * clock) fails the true placement; the minimum keeps a lower one from being + * taken on a row or two that happen to be equal. + */ +export function findFrameInScreen(screenRows: string[], frameRows: string[]): number { + const screen = screenRows.map(capturedRowText); + const frame = frameRows.map(capturedRowText); + let lastContent = screen.length - 1; + while (lastContent >= 0 && screen[lastContent] === '') lastContent--; + for (let start = 0; start < lastContent; start++) { + const overlap = Math.min(lastContent - start, frame.length); + if (overlap < FRAME_MATCH_MIN_ROWS) break; // only shrinks from here on + let matches = true; + let contentRows = 0; + for (let k = 0; k < overlap && matches; k++) { + if (screen[start + k] !== frame[k]) matches = false; + else if (frame[k] !== '') contentRows++; + } + if (matches && contentRows >= FRAME_MATCH_MIN_CONTENT_ROWS) return start; + } + return -1; +} + /** * Put scrollback rows read elsewhere (`PaneCaptureOptions.scrollbackOverride`) * above a visible-frame capture, as one capture in tmux's own shape: rows joined * by a bare `\n`. Undefined or empty scrollback leaves the frame as it is. + * + * `screen` is that other pane's screen, read right after its scrollback. The + * frame is captured later, so rows can have scrolled out of it in between, and + * a frame showing part of a taller screen starts below that screen's top. + * Either way the screen rows above the frame's first row are in neither the + * scrollback nor the frame, so they go between the two. When the frame cannot + * be placed in the screen ({@link findFrameInScreen}), the join is the plain + * scrollback-over-frame one. */ -export function joinScrollbackAndFrame(scrollback: string | undefined, frame: string): string { - if (!scrollback) return frame; - return `${scrollback.replace(/\n$/, '')}\n${frame}`; +export function joinScrollbackAndFrame(scrollback: string | undefined, frame: string, screen?: string): string { + let above = scrollback ? captureRows(scrollback) : []; + if (screen) { + const screenRows = captureRows(screen); + const frameStart = findFrameInScreen(screenRows, captureRows(frame)); + if (frameStart > 0) above = above.concat(screenRows.slice(0, frameStart)); + } + return above.length > 0 ? `${above.join('\n')}\n${frame}` : frame; } /** Pane geometry and caret position, as `display-message` reports them. */ @@ -3915,7 +3986,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } const rawCapture = joinScrollbackAndFrame( scrollbackOverride, - execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts) + execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts), + scrollbackOverride !== undefined ? opts?.scrollbackOverrideScreen : undefined ); // Query the cursor BEFORE deciding anything else. On the full-history path // it settles both how the capture is trimmed and whether a cursor move is diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 06f1407d..e143600a 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -3128,7 +3128,10 @@ export function registerSessionRoutes( captureOpts.historyLimitLines, terminalBufferMaxBytes ); - if (remoteScrollback !== null) captureOpts.scrollbackOverride = remoteScrollback; + if (remoteScrollback !== null) { + captureOpts.scrollbackOverride = remoteScrollback.scrollback; + captureOpts.scrollbackOverrideScreen = remoteScrollback.screen; + } } const liveMuxBuffer = muxName && typeof ctx.mux.captureActivePaneBuffer === 'function' diff --git a/test/remote-pane-history.test.ts b/test/remote-pane-history.test.ts index 144df6cc..667ab529 100644 --- a/test/remote-pane-history.test.ts +++ b/test/remote-pane-history.test.ts @@ -28,7 +28,7 @@ import { getRemoteSshLimit, runWithRemoteSshLimit, } from '../src/remote-ssh-limiter.js'; -import { joinScrollbackAndFrame, remoteTmuxLocation } from '../src/tmux-manager.js'; +import { findFrameInScreen, joinScrollbackAndFrame, remoteTmuxLocation } from '../src/tmux-manager.js'; import type { SessionRemote } from '../src/types/session.js'; const SESSION_ID = '663e3eae-8da6-4c7f-9231-611eb2840641'; @@ -56,7 +56,7 @@ describe('remoteTmuxLocation', () => { }); describe('buildRemoteScrollbackCommand', () => { - it('goes through the shared ssh options and reads history rows only', () => { + it('goes through the shared ssh options and reads the history rows, then the screen', () => { const command = buildRemoteScrollbackCommand(remote, SESSION_ID, 10000, 2048); expect(command.startsWith('exec ssh -o BatchMode=yes')).toBe(true); expect(command).toContain('-p 2222'); @@ -67,6 +67,8 @@ describe('buildRemoteScrollbackCommand', () => { expect(command).toContain('capture-pane -p -e -J -S -10000 -E -1'); expect(command).toContain('#{history_size}'); expect(command).toContain('| tail -c 2048'); + // The screen capture comes after the history and is not under the byte bound. + expect(command.indexOf('-E -1')).toBeLessThan(command.lastIndexOf('capture-pane -p -e -J -t')); }); it('a byte cap of 0 (unbounded) runs no tail', () => { @@ -85,18 +87,28 @@ describe('dropPartialFirstRow', () => { describe('parseRemoteScrollbackOutput', () => { it('drops anything printed before the script (login banner, rc-file echo)', () => { - expect(parseRemoteScrollbackOutput('Welcome to proxx\n\x0042\n\x00line a\nline b\n')).toBe('line a\nline b\n'); + expect( + parseRemoteScrollbackOutput('Welcome to proxx\n\x0042\n\x00line a\nline b\n\x00screen 1\nscreen 2\n') + ).toEqual({ + scrollback: 'line a\nline b\n', + screen: 'screen 1\nscreen 2\n', + }); }); it('an empty history is empty, not the first visible row tmux clamps -E -1 to', () => { - expect(parseRemoteScrollbackOutput('\x000\n\x00$ the prompt row\n')).toBe(''); + expect(parseRemoteScrollbackOutput('\x000\n\x00$ the prompt row\n\x00$ the prompt row\n')).toEqual({ + scrollback: '', + screen: '$ the prompt row\n', + }); }); it('rejects output that is not the script’s', () => { expect(parseRemoteScrollbackOutput('')).toBeNull(); expect(parseRemoteScrollbackOutput('no markers at all')).toBeNull(); expect(parseRemoteScrollbackOutput('\x0042\n')).toBeNull(); - expect(parseRemoteScrollbackOutput('\x00not a number\n\x00rows')).toBeNull(); + // The screen marker is missing: the script stopped after the history. + expect(parseRemoteScrollbackOutput('\x0042\n\x00rows\n')).toBeNull(); + expect(parseRemoteScrollbackOutput('\x00not a number\n\x00rows\x00screen')).toBeNull(); }); }); @@ -117,7 +129,8 @@ describe('the remote script under a real shell', () => { `printf -- '--\\n' >> '${callsFile}'`, 'case " $* " in', ' *" display-message "*) echo 2 ;;', - ' *" capture-pane "*) printf "older row\\nnewer row\\n" ;;', + ' *" -E -1 "*) printf "older row\\nnewer row\\n" ;;', + ' *" capture-pane "*) printf "screen row\\n" ;;', 'esac', ].join('\n') ); @@ -134,13 +147,15 @@ describe('the remote script under a real shell', () => { encoding: 'utf-8', env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` }, }); - // `tail -c 10` kept the newest bytes only: "newer row\n". - expect(parseRemoteScrollbackOutput(stdout)).toBe('newer row\n'); + // `tail -c 10` kept the newest history bytes only: "newer row\n"; the + // screen after it is not under that bound. + expect(parseRemoteScrollbackOutput(stdout)).toEqual({ scrollback: 'newer row\n', screen: 'screen row\n' }); const calls = execFileSync('cat', [callsFile], { encoding: 'utf-8' }).split('--\n').filter(Boolean); - expect(calls).toHaveLength(2); + expect(calls).toHaveLength(3); // One argv entry per line: the exact-match target with a quote in it arrived whole. expect(calls[0].split('\n').join('|')).toBe("-L|codeman|display-message|-p|-t|=codeman-it's:|#{history_size}|"); expect(calls[1].split('\n').join('|')).toBe("-L|codeman|capture-pane|-p|-e|-J|-S|-500|-E|-1|-t|=codeman-it's:|"); + expect(calls[2].split('\n').join('|')).toBe("-L|codeman|capture-pane|-p|-e|-J|-t|=codeman-it's:|"); }); }); @@ -156,9 +171,90 @@ describe('joinScrollbackAndFrame', () => { }); }); +// The remote screen is read right after the remote history, the local frame +// only once the round trip is back. Rows between the two are on that screen +// alone, so the screen rows above where the frame overlaps it are kept. +describe('joinScrollbackAndFrame with the remote screen', () => { + const rows = (...r: string[]) => `${r.join('\n')}\n`; + const captureRowsOf = (text: string) => text.replace(/\n$/, '').split('\n'); + const H = rows('h1', 'h2'); + + it('nothing moved: the frame IS the screen, so no screen row is added', () => { + const screen = rows('s1', 's2', 's3', '$ ', '', ''); + expect(joinScrollbackAndFrame(H, screen, screen)).toBe(`${H}${screen}`); + }); + + it('rows that scrolled off during the round trip are kept, once each', () => { + const screen = rows('s1', 's2', 's3', 's4', 's5', 's6', 's7', '$ make'); + // Three more rows of output arrived: s1..s3 scrolled out of the frame. + const frame = rows('s4', 's5', 's6', 's7', '$ make', 'out 1', 'out 2', 'out 3'); + expect(joinScrollbackAndFrame(H, frame, screen)).toBe(`${H}${rows('s1', 's2', 's3')}${frame}`); + }); + + it('the top of a remote screen taller than this pane is kept', () => { + const screen = rows('t1', 't2', 's1', 's2', 's3', '$ '); + const frame = rows('s1', 's2', 's3', '$ '); + expect(joinScrollbackAndFrame(H, frame, screen)).toBe(`${H}${rows('t1', 't2')}${frame}`); + }); + + it('the last content row may have changed (output in flight) and still matches', () => { + const screen = rows('s1', 's2', 's3', 's4', '$ par'); + const frame = rows('s2', 's3', 's4', '$ partial line done', 'next'); + expect(joinScrollbackAndFrame(H, frame, screen)).toBe(`${H}${rows('s1')}${frame}`); + }); + + it('compares text only: two tmuxes may encode the same attributes differently', () => { + const screen = rows('\x1b[38;5;1ms1\x1b[39m', 's2 ', 's3', 's4', '$ '); + const frame = rows('s2', '\x1b[1ms3\x1b[0m', 's4', '$ ', 'new'); + expect(joinScrollbackAndFrame(H, frame, screen)).toBe(`${H}${rows('\x1b[38;5;1ms1\x1b[39m')}${frame}`); + }); + + it('no overlap (more scrolled than the screen held) falls back to scrollback over frame', () => { + const screen = rows('s1', 's2', '$ '); + const frame = rows('x1', 'x2', 'x3'); + expect(joinScrollbackAndFrame(H, frame, screen)).toBe(`${H}${frame}`); + }); + + it('an overlap of blank rows alone is not a match', () => { + const screen = rows('s1', '', '', '$ '); + const frame = rows('', '', 'other', 'rows'); + expect(joinScrollbackAndFrame(H, frame, screen)).toBe(`${H}${frame}`); + }); + + it('the first (highest) match wins over a block that repeats lower down', () => { + const screen = rows('a', 'b', 'a', 'b', '$ '); + const frame = rows('a', 'b', 'a', 'b', '$ x'); + expect(joinScrollbackAndFrame(H, frame, screen)).toBe(`${H}${frame}`); + }); + + // A placement too LOW repeats rows, which is worse than the plain join's + // loss: a row that changed between the reads (a spinner) fails the true + // placement, and a row or two that happen to be equal further down must not + // be taken instead. + it('does not trust an overlap shorter than three rows, two of them with text', () => { + const screen = rows('s1', 'spin |', 's3', '$', 'out', '$ '); + const frame = rows('spin /', 's3', '$', 'out', '$ ', 'new'); + expect(findFrameInScreen(captureRowsOf(screen), captureRowsOf(frame))).toBe(-1); + expect(joinScrollbackAndFrame(H, frame, screen)).toBe(`${H}${frame}`); + // Three compared rows but only one with text: not trusted either. + expect(findFrameInScreen(['a', 'x', '', '', 'z'], ['x', '', '', 'z', 'q'])).toBe(-1); + }); + + it('keeps a scrollback that is one blank row, as the plain join did', () => { + expect(joinScrollbackAndFrame('\n', 'frame\n')).toBe('\nframe\n'); + }); + + it('places the frame among screen rows with findFrameInScreen', () => { + expect(findFrameInScreen(['a', 'b', 'c', 'd', '$'], ['b', 'c', 'd', '$', 'e'])).toBe(1); + expect(findFrameInScreen(['a', 'b', 'c', '$'], ['z'])).toBe(-1); + expect(findFrameInScreen([], ['a'])).toBe(-1); + expect(findFrameInScreen(['only row'], ['only row'])).toBe(-1); + }); +}); + describe('fetchRemoteScrollback', () => { - const OK = '\x002\n\x00older row\nnewer row\n'; - const READ = 'older row\nnewer row\n'; + const OK = '\x002\n\x00older row\nnewer row\n\x00screen row\n'; + const READ = { scrollback: 'older row\nnewer row\n', screen: 'screen row\n' }; /** An exec-shaped rejection: `code` is the exit status, `killed` a timeout kill. */ const execError = (fields: { code?: number | string; killed?: boolean }) => Object.assign(new Error('Command failed'), fields); @@ -182,7 +278,8 @@ describe('fetchRemoteScrollback', () => { expect(await fetchRemoteScrollback(remote, SESSION_ID, 100, 1024, run)).toEqual(READ); expect(run).toHaveBeenCalledWith(buildRemoteScrollbackCommand(remote, SESSION_ID, 100, 1024), { timeout: 5000, - maxBuffer: 1024 + 64 * 1024, + // The byte cap covers the history; the slack covers the screen after it. + maxBuffer: 1024 + 1024 * 1024, }); }); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 5f2efff2..9a0b6f35 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1161,7 +1161,10 @@ describe('session-routes', () => { return captureSpy.mock.calls[0]?.[1]; }; - remoteScrollback.mockResolvedValueOnce('remote line 1\nremote line 2\n'); + remoteScrollback.mockResolvedValueOnce({ + scrollback: 'remote line 1\nremote line 2\n', + screen: 'remote screen\n', + }); const opts = await load('full=1&lines=500'); expect(remoteScrollback).toHaveBeenCalledWith(remote, harness.ctx._sessionId, 500, expect.any(Number)); expect(opts).toEqual( @@ -1169,6 +1172,7 @@ describe('session-routes', () => { fullHistory: true, historyLimitLines: 500, scrollbackOverride: 'remote line 1\nremote line 2\n', + scrollbackOverrideScreen: 'remote screen\n', }) ); From 8e2594a2746dcf3035ec7a62e659e7878211db59 Mon Sep 17 00:00:00 2001 From: timkjr Date: Sun, 11 Oct 2026 07:53:30 -0500 Subject: [PATCH 2/3] fix(remote): read the remote history and screen in one tmux command list 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) --- docs/remote-sessions.md | 26 +++-- src/remote-pane-history.ts | 154 ++++++++++++++++++-------- test/remote-pane-history.test.ts | 179 ++++++++++++++++++++++++------- 3 files changed, 265 insertions(+), 94 deletions(-) diff --git a/docs/remote-sessions.md b/docs/remote-sessions.md index a592be7f..f050fed4 100644 --- a/docs/remote-sessions.md +++ b/docs/remote-sessions.md @@ -266,8 +266,8 @@ command itself (a remote tmux session that is gone) affects that session only. How much history exists is the remote tmux's own `history-limit`, which the launch command does not set (tmux's default is 2000 lines). -The same round trip also reads the remote SCREEN, right after the history, -because two seams would otherwise drop rows at the join: +The same round trip also reads the remote SCREEN, in the same tmux command list +as the history, because two seams would otherwise drop rows at the join: - **Rows that scroll during the round trip.** The local frame is captured only once the round trip is back, so rows that scrolled off the screen in between @@ -289,11 +289,23 @@ with two of them non-blank, and the highest match wins. A placement too low would REPEAT the rows between it and the true one, so a shorter overlap (a lone `$` row that happens to match after a spinner row changed) is not trusted. When the frame cannot be placed (more output scrolled than the screen held, or -nothing matches), the join is history over frame, as without the screen read. What is left: the history and screen captures are two -tmux commands, so a row that scrolls between them (milliseconds apart, not the -round trip) is in neither, and 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. +nothing matches), the join is history over frame, as without the screen read. +What is left: 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. + +The history, the history size and the screen are ONE tmux command list +(`capture-pane … \; display-message … \; capture-pane …`), not separate `tmux` +runs. tmux runs a client's command list back to back without reading pane +output in between, so the screen begins exactly where the history ends. As +separate runs the shell kept writing between them and the rows that scrolled in +those milliseconds were in neither capture (measured with output streaming: 1-2 +rows per load on a LAN host, every read on a local busy pane). This rests on how +tmux's command queue behaves, not on a documented guarantee. A random marker row +(`display-message`) separates the history from the size and the screen, since +tmux output cannot carry the NUL markers around it. The remote `tail -c` bound +applies to that whole output, history first, and is the configured byte cap +plus 1 MiB for the marker, size and screen, so a cut lands in the history; the +route trims what it returns to the cap itself. ## File access over SSH diff --git a/src/remote-pane-history.ts b/src/remote-pane-history.ts index 91f30ade..5f161744 100644 --- a/src/remote-pane-history.ts +++ b/src/remote-pane-history.ts @@ -11,15 +11,24 @@ * the local pane is what the live stream keeps drawing into, so its frame and caret * position are the ones the browser must end up with. * - * ONE ssh round trip prints the remote pane's `#{history_size}`, its scrollback - * rows (`capture-pane -S - -E -1`) and then its screen. The size is load-bearing: - * with no history at all, tmux clamps `-E -1` to the first VISIBLE row and would - * return it as history. The screen closes the seam between the two captures: the - * local frame is read only after the round trip, so rows that scrolled in between, + * ONE ssh round trip reads the remote pane's scrollback rows (`capture-pane -S - + * -E -1`), its `#{history_size}` and its screen. The size is load-bearing: with no + * history at all, tmux clamps `-E -1` to the first VISIBLE row and would return it + * as history. The screen closes the seam between the remote read and the local + * frame, which is read only after the round trip: rows that scrolled in between, * and the top of a remote screen taller than the local pane, are on that screen * alone, and the caller keeps the screen rows above where the local frame overlaps * it (`joinScrollbackAndFrame`). * + * ⚠️ The three reads are ONE tmux command list (`a \; b \; c`), not three tmux + * runs. tmux runs a client's command list back to back without reading pane output + * in between, so the history ends exactly where the screen begins. As separate runs + * the shell kept writing between them, and the rows that scrolled in those + * milliseconds were in neither capture (measured on a busy pane: every read lost + * rows). This rests on how tmux's command queue behaves, not on a documented + * guarantee. tmux output cannot carry the NUL markers, so a random marker line + * printed by `display-message` separates the history from the size and screen. + * * Bounded and fail-soft. Any failure (unreachable host, auth, missing session, odd * output) returns null and the caller keeps the local capture, so a remote load is * never worse than before. A transport failure also starts a short per-host back-off, @@ -34,6 +43,7 @@ */ import { exec } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; import { promisify } from 'node:util'; import type { SessionRemote } from './types/session.js'; import { buildSshConnectionArgs, classifyRemoteAliveExit, remoteSshTarget, shellescape } from './remote-hosts.js'; @@ -59,11 +69,24 @@ const REMOTE_HISTORY_SLOT_WAIT_MS = 1_500; const REMOTE_HISTORY_BACKOFF_MS = 30_000; /** - * Headroom over the remote `tail -c` bound for the size line, the markers and - * the screen capture, which the bound does not cover (a wide, tall screen of - * colored rows runs to a few hundred KB). + * Headroom over the remote `tail -c` bound for what it does not cover: the two NUL + * markers and anything a login banner or rc file prints before the script. */ -const REMOTE_HISTORY_SLACK_BYTES = 1024 * 1024; +const REMOTE_HISTORY_SLACK_BYTES = 64 * 1024; + +/** + * Added to the configured byte cap for the remote `tail -c` bound. That bound + * covers the whole tmux output, and the marker, the size and the screen come last + * (a wide, tall screen of colored rows runs to a few hundred KB); without this, a + * small cap would cut into the screen and lose the read. The route trims what it + * returns to the configured cap anyway, keeping the newest bytes. + */ +const REMOTE_SCREEN_ALLOWANCE_BYTES = 1024 * 1024; + +/** The remote `tail -c` bound for a configured byte cap; 0 (unbounded) stays 0. */ +export function remoteTailBytes(maxBytes: number): number { + return maxBytes > 0 ? Math.trunc(maxBytes) + REMOTE_SCREEN_ALLOWANCE_BYTES : 0; +} /** Read ceiling when the configured byte cap is 0 (unbounded) and no `tail -c` runs. */ const UNBOUNDED_READ_MAX_BYTES = 64 * 1024 * 1024; @@ -75,17 +98,28 @@ const UNBOUNDED_READ_MAX_BYTES = 64 * 1024 * 1024; const backoffUntil = new Map(); /** - * The remote shell script. Its first byte is a NUL so a login banner or an rc-file - * `echo` printed before it runs is separated without guessing (the same marker - * `buildRemoteProbeCommand` uses); a second NUL ends the history size. + * A fresh marker line for one read. Random, so no row the pane really holds can be + * taken for it. + */ +export function newScrollbackMarker(): string { + return `codeman-history-${randomBytes(8).toString('hex')}`; +} + +/** + * The remote shell script. It prints a NUL, the tmux output, then a second NUL: + * the first separates a login banner or an rc-file `echo` printed before the + * script runs without guessing (the same marker `buildRemoteProbeCommand` uses), + * and both are printed whether or not tmux succeeds, so their presence says the + * script ran on the host. The tmux output is the history rows, `marker`, the + * history size, then the screen rows. */ export function buildRemoteScrollbackScript( socket: string, sessionName: string, historyLines: number, - maxBytes: number + tailBytes: number, + marker: string ): string { - const tmux = `tmux -L ${shellescape(socket)}`; // `=name:` is an EXACT session match, then its active pane. A bare name is // read as a pane index or window name first, so a discovered session named // `0` would capture some other session's pane. @@ -93,15 +127,18 @@ export function buildRemoteScrollbackScript( const lines = Math.max(1, Math.trunc(historyLines)); // The newest rows are the ones worth keeping, so the byte bound is applied on // the remote side (`tail -c`) rather than by killing the read at maxBuffer. - // A cap of 0 means unbounded, as in the route. - const bound = maxBytes > 0 ? ` | tail -c ${Math.trunc(maxBytes)}` : ''; - // The screen comes right after the history, in the same round trip: the - // local frame is captured only once this returns, so rows that scroll off - // in between are on this screen and nowhere else (joinScrollbackAndFrame). + // It covers the whole tmux output, history first, so with the screen + // allowance in `remoteTailBytes()` a cut lands in the history. 0 means + // unbounded, as in the route. + const bound = tailBytes > 0 ? ` | tail -c ${Math.trunc(tailBytes)}` : ''; + // ONE tmux command list, so no pane output is read between the history and + // the screen (see the @fileoverview). `\;` reaches tmux as a lone `;`. return ( - `printf '\\0' && ${tmux} display-message -p -t ${target} '#{history_size}' && printf '\\0' && ` + - `${tmux} capture-pane -p -e -J -S -${lines} -E -1 -t ${target}${bound} && printf '\\0' && ` + - `${tmux} capture-pane -p -e -J -t ${target}` + `printf '\\0'; tmux -L ${shellescape(socket)} ` + + `capture-pane -p -e -J -S -${lines} -E -1 -t ${target} \\; ` + + `display-message -p -t ${target} ${shellescape(marker)} \\; ` + + `display-message -p -t ${target} '#{history_size}' \\; ` + + `capture-pane -p -e -J -t ${target}${bound}; printf '\\0'` ); } @@ -110,10 +147,11 @@ export function buildRemoteScrollbackCommand( remote: SessionRemote, sessionId: string, historyLines: number, - maxBytes: number + tailBytes: number, + marker: string ): string { const { socket, sessionName } = remoteTmuxLocation(remote, sessionId); - const script = buildRemoteScrollbackScript(socket, sessionName, historyLines, maxBytes); + const script = buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker); // `exec` so a timeout kill reaches ssh itself, not only the local `sh -c`. return ['exec', ...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(script)].join(' '); } @@ -132,27 +170,41 @@ export function dropPartialFirstRow(scrollback: string, maxBytes: number): strin export interface RemoteScrollback { /** History rows above the screen, oldest first; '' when the pane has none. */ scrollback: string; - /** The screen's rows, read right after the scrollback. */ + /** The screen's rows, read in the same tmux command list, so they begin where the scrollback ends. */ screen: string; } /** * The scrollback and screen rows from the script's stdout, or null when the - * output is not the script's. + * output is not the script's or tmux printed nothing usable (no such session, + * or a byte cap so small it cut into the screen). */ -export function parseRemoteScrollbackOutput(stdout: string): RemoteScrollback | null { +export function parseRemoteScrollbackOutput( + stdout: string, + marker: string, + tailBytes: number +): RemoteScrollback | null { const start = stdout.indexOf('\0'); if (start === -1) return null; - const sizeEnd = stdout.indexOf('\0', start + 1); + const end = stdout.indexOf('\0', start + 1); + if (end === -1) return null; + // The marker is a whole row of its own, after the history's last row. + const markerRow = `${marker}\n`; + const raw = stdout.slice(start + 1, end); + // A cut that landed exactly on the marker row left it whole: there is no + // partial history row to drop, and dropping the first row would lose it. + const output = raw.startsWith(markerRow) ? raw : dropPartialFirstRow(raw, tailBytes); + const at = output.startsWith(markerRow) ? 0 : output.indexOf(`\n${markerRow}`) + 1; + if (at === 0 && !output.startsWith(markerRow)) return null; + const sizeStart = at + markerRow.length; + const sizeEnd = output.indexOf('\n', sizeStart); if (sizeEnd === -1) return null; - const historyEnd = stdout.indexOf('\0', sizeEnd + 1); - if (historyEnd === -1) return null; - const sizeText = stdout.slice(start + 1, sizeEnd).trim(); + const sizeText = output.slice(sizeStart, sizeEnd).trim(); if (!/^\d+$/.test(sizeText)) return null; - const screen = stdout.slice(historyEnd + 1); + const screen = output.slice(sizeEnd + 1); // With no history at all, tmux clamps `-E -1` to the first visible row. if (Number(sizeText) === 0) return { scrollback: '', screen }; - return { scrollback: stdout.slice(sizeEnd + 1, historyEnd), screen }; + return { scrollback: output.slice(0, at), screen }; } /** Runs the ssh command line and resolves its stdout; rejects like `exec` does. */ @@ -168,8 +220,8 @@ const execRunner: RemoteScrollbackRunner = async (command, opts) => * Only a TRANSPORT failure backs the host off (ssh's own exit 255, a timeout, a * spawn error, output that is not the script's): those say the host is * unreachable for every session on it. A remote command failing (a session - * that is gone: tmux exits 1, or answers nothing between the markers when the - * `| tail -c` pipe hides its status) is about this session only, so it returns + * that is gone: tmux answers nothing between the NULs, and the closing `printf` + * hides its exit status) is about this session only, so it returns * null and the next session on that host still tries. Same split as * `classifyRemoteAliveExit()`. * @@ -190,32 +242,42 @@ export async function fetchRemoteScrollback( if (Date.now() < until) return null; backoffUntil.delete(remote.hostId); } - const command = buildRemoteScrollbackCommand(remote, sessionId, historyLines, maxBytes); + const marker = newScrollbackMarker(); + const tailBytes = remoteTailBytes(maxBytes); + const command = buildRemoteScrollbackCommand(remote, sessionId, historyLines, tailBytes, marker); try { const stdout = await runWithRemoteSshLimit( () => runner(command, { timeout: REMOTE_HISTORY_TIMEOUT_MS, - maxBuffer: (maxBytes > 0 ? maxBytes : UNBOUNDED_READ_MAX_BYTES) + REMOTE_HISTORY_SLACK_BYTES, + maxBuffer: (tailBytes > 0 ? tailBytes : UNBOUNDED_READ_MAX_BYTES) + REMOTE_HISTORY_SLACK_BYTES, }), // The pool is shared with 20 s file probes; past this the local capture // is the better answer than waiting behind them. { maxWaitMs: REMOTE_HISTORY_SLOT_WAIT_MS } ); - const parsed = parseRemoteScrollbackOutput(stdout); + const parsed = parseRemoteScrollbackOutput(stdout, marker, tailBytes); if (parsed === null) { - // Both markers arrived, so the script ran on the host and only tmux had - // nothing to say: no such session. The `| tail -c` pipe hides tmux's - // exit status, so this comes back as exit 0 rather than exit 1, and it - // is about this session alone. Output without the markers is not the - // script's at all, which says the host is not answering as expected. - const ranOnHost = stdout.split('\0').length >= 3; + // Both NULs arrived, so the script ran on the host and only tmux had + // nothing to say: no such session. The script's last command is the + // closing `printf`, so this comes back as exit 0 rather than tmux's + // exit 1, and it is about this session alone. Output without the NULs + // is not the script's at all, which says the host is not answering as + // expected. + const parts = stdout.split('\0'); + const ranOnHost = parts.length >= 3; if (!ranOnHost) backoffUntil.set(remote.hostId, Date.now() + REMOTE_HISTORY_BACKOFF_MS); - const what = ranOnHost ? 'no such remote tmux session' : 'unexpected output'; + // tmux printed something, but not the marker, size and screen in order. + const unreadable = ranOnHost && parts[1] !== ''; + const what = !ranOnHost + ? 'unexpected output' + : unreadable + ? 'unreadable remote tmux output' + : 'no such remote tmux session'; console.warn(`[remote-history] ${remote.label}: ${what}; using the local capture`); return null; } - return { scrollback: dropPartialFirstRow(parsed.scrollback, maxBytes), screen: parsed.screen }; + return parsed; } catch (err) { // A busy pool says nothing about the host. if (err instanceof RemoteSshSlotTimeoutError) return null; diff --git a/test/remote-pane-history.test.ts b/test/remote-pane-history.test.ts index 667ab529..33629f33 100644 --- a/test/remote-pane-history.test.ts +++ b/test/remote-pane-history.test.ts @@ -19,7 +19,9 @@ import { buildRemoteScrollbackScript, dropPartialFirstRow, fetchRemoteScrollback, + newScrollbackMarker, parseRemoteScrollbackOutput, + remoteTailBytes, resetRemoteScrollbackBackoff, } from '../src/remote-pane-history.js'; import { @@ -55,9 +57,11 @@ describe('remoteTmuxLocation', () => { }); }); +const MARKER = 'codeman-history-0123456789abcdef'; + describe('buildRemoteScrollbackCommand', () => { it('goes through the shared ssh options and reads the history rows, then the screen', () => { - const command = buildRemoteScrollbackCommand(remote, SESSION_ID, 10000, 2048); + const command = buildRemoteScrollbackCommand(remote, SESSION_ID, 10000, 2048, MARKER); expect(command.startsWith('exec ssh -o BatchMode=yes')).toBe(true); expect(command).toContain('-p 2222'); expect(command).toContain('tim@proxx.lan'); @@ -66,13 +70,31 @@ describe('buildRemoteScrollbackCommand', () => { expect(command).toContain('codeman-ssh-663e3eae'); expect(command).toContain('capture-pane -p -e -J -S -10000 -E -1'); expect(command).toContain('#{history_size}'); + expect(command).toContain(MARKER); expect(command).toContain('| tail -c 2048'); - // The screen capture comes after the history and is not under the byte bound. expect(command.indexOf('-E -1')).toBeLessThan(command.lastIndexOf('capture-pane -p -e -J -t')); }); it('a byte cap of 0 (unbounded) runs no tail', () => { - expect(buildRemoteScrollbackCommand(remote, SESSION_ID, 10000, 0)).not.toContain('tail -c'); + expect(buildRemoteScrollbackCommand(remote, SESSION_ID, 10000, 0, MARKER)).not.toContain('tail -c'); + }); +}); + +describe('remoteTailBytes', () => { + it('adds room for the marker, size and screen to the cap, so a cut lands in the history', () => { + expect(remoteTailBytes(2048)).toBe(2048 + 1024 * 1024); + }); + + it('keeps 0 (unbounded) unbounded', () => { + expect(remoteTailBytes(0)).toBe(0); + }); +}); + +describe('newScrollbackMarker', () => { + it('is a fresh marker of a fixed shape on every read', () => { + const a = newScrollbackMarker(); + expect(a).toMatch(/^codeman-history-[0-9a-f]{16}$/); + expect(newScrollbackMarker()).not.toBe(a); }); }); @@ -86,9 +108,15 @@ describe('dropPartialFirstRow', () => { }); describe('parseRemoteScrollbackOutput', () => { + const read = (tmuxOutput: string) => `\x00${tmuxOutput}\x00`; + it('drops anything printed before the script (login banner, rc-file echo)', () => { expect( - parseRemoteScrollbackOutput('Welcome to proxx\n\x0042\n\x00line a\nline b\n\x00screen 1\nscreen 2\n') + parseRemoteScrollbackOutput( + `Welcome to proxx\n${read(`line a\nline b\n${MARKER}\n42\nscreen 1\nscreen 2\n`)}`, + MARKER, + 0 + ) ).toEqual({ scrollback: 'line a\nline b\n', screen: 'screen 1\nscreen 2\n', @@ -96,19 +124,48 @@ describe('parseRemoteScrollbackOutput', () => { }); it('an empty history is empty, not the first visible row tmux clamps -E -1 to', () => { - expect(parseRemoteScrollbackOutput('\x000\n\x00$ the prompt row\n\x00$ the prompt row\n')).toEqual({ + expect(parseRemoteScrollbackOutput(read(`$ the prompt row\n${MARKER}\n0\n$ the prompt row\n`), MARKER, 0)).toEqual({ scrollback: '', screen: '$ the prompt row\n', }); }); - it('rejects output that is not the script’s', () => { - expect(parseRemoteScrollbackOutput('')).toBeNull(); - expect(parseRemoteScrollbackOutput('no markers at all')).toBeNull(); - expect(parseRemoteScrollbackOutput('\x0042\n')).toBeNull(); - // The screen marker is missing: the script stopped after the history. - expect(parseRemoteScrollbackOutput('\x0042\n\x00rows\n')).toBeNull(); - expect(parseRemoteScrollbackOutput('\x00not a number\n\x00rows\x00screen')).toBeNull(); + it('a history the byte cap cut loses its partial first row, and the screen is kept whole', () => { + const output = `ow 1\nrow 2\n${MARKER}\n7\nscreen\n`; + expect(parseRemoteScrollbackOutput(read(output), MARKER, Buffer.byteLength(output))).toEqual({ + scrollback: 'row 2\n', + screen: 'screen\n', + }); + }); + + it('a cut that landed exactly on the marker row keeps it', () => { + const output = `${MARKER}\n3\nscreen\n`; + expect(parseRemoteScrollbackOutput(read(output), MARKER, Buffer.byteLength(output))).toEqual({ + scrollback: '', + screen: 'screen\n', + }); + }); + + it('finds the marker only as a whole row', () => { + const output = `echo ${MARKER}\n${MARKER}\n2\nscreen\n`; + expect(parseRemoteScrollbackOutput(read(output), MARKER, 0)).toEqual({ + scrollback: `echo ${MARKER}\n`, + screen: 'screen\n', + }); + }); + + it('rejects output that is not the script’s, or tmux output with nothing usable', () => { + expect(parseRemoteScrollbackOutput('', MARKER, 0)).toBeNull(); + expect(parseRemoteScrollbackOutput('no markers at all', MARKER, 0)).toBeNull(); + // The closing NUL is missing: the connection dropped mid-read. + expect(parseRemoteScrollbackOutput(`\x00rows\n${MARKER}\n2\nscreen\n`, MARKER, 0)).toBeNull(); + // Both NULs, nothing between: tmux found no such session. + expect(parseRemoteScrollbackOutput('\x00\x00', MARKER, 0)).toBeNull(); + // Another read's marker. + expect( + parseRemoteScrollbackOutput(read('rows\ncodeman-history-ffffffffffffffff\n2\nscreen\n'), MARKER, 0) + ).toBeNull(); + expect(parseRemoteScrollbackOutput(read(`rows\n${MARKER}\nnot a number\nscreen\n`), MARKER, 0)).toBeNull(); }); }); @@ -119,19 +176,27 @@ describe('the remote script under a real shell', () => { beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'remote-scrollback-')); callsFile = join(dir, 'calls'); - // Stub tmux: logs its argv (one per line), answers display-message with a - // history size and capture-pane with two rows. + // Stub tmux: logs its argv (one per line) and runs a `;`-separated command + // list in order, as tmux does: the history size, the marker it was given, + // two history rows, one screen row. writeFileSync( join(dir, 'tmux'), [ '#!/bin/sh', `for a in "$@"; do printf '%s\\n' "$a"; done >> '${callsFile}'`, `printf -- '--\\n' >> '${callsFile}'`, - 'case " $* " in', - ' *" display-message "*) echo 2 ;;', - ' *" -E -1 "*) printf "older row\\nnewer row\\n" ;;', - ' *" capture-pane "*) printf "screen row\\n" ;;', - 'esac', + 'run() {', + ' case " $1 " in', + ' *" #{history_size} "*) echo 2 ;;', + ` *" display-message "*) printf '%s\\n' "$2" ;;`, + ' *" -E -1 "*) printf "older row\\nnewer row\\n" ;;', + ' *" capture-pane "*) printf "screen row\\n" ;;', + ' esac', + '}', + "cmd=''; last=''", + `for a in "$@" ';'; do`, + ` if [ "$a" = ';' ]; then run "$cmd" "$last"; cmd=''; else cmd="$cmd $a"; last="$a"; fi`, + 'done', ].join('\n') ); chmodSync(join(dir, 'tmux'), 0o755); @@ -141,21 +206,47 @@ describe('the remote script under a real shell', () => { rmSync(dir, { recursive: true, force: true }); }); - it('prints the markers and rows the parser expects, with the target as one argument', () => { - const script = buildRemoteScrollbackScript('codeman', "codeman-it's", 500, 10); + it('prints the markers and rows the parser expects, from ONE tmux command list', () => { + // A bound that keeps "row\nnewer row\n" of the history: the cut lands in + // "older row", which the parser then drops. + const bound = Buffer.byteLength(`row\nnewer row\n${MARKER}\n2\nscreen row\n`); + const script = buildRemoteScrollbackScript('codeman', "codeman-it's", 500, bound, MARKER); const stdout = execFileSync('/bin/sh', ['-c', script], { encoding: 'utf-8', env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` }, }); - // `tail -c 10` kept the newest history bytes only: "newer row\n"; the - // screen after it is not under that bound. - expect(parseRemoteScrollbackOutput(stdout)).toEqual({ scrollback: 'newer row\n', screen: 'screen row\n' }); + expect(parseRemoteScrollbackOutput(stdout, MARKER, bound)).toEqual({ + scrollback: 'newer row\n', + screen: 'screen row\n', + }); const calls = execFileSync('cat', [callsFile], { encoding: 'utf-8' }).split('--\n').filter(Boolean); - expect(calls).toHaveLength(3); + // One tmux run: separate runs would let the pane scroll between the reads. + expect(calls).toHaveLength(1); // One argv entry per line: the exact-match target with a quote in it arrived whole. - expect(calls[0].split('\n').join('|')).toBe("-L|codeman|display-message|-p|-t|=codeman-it's:|#{history_size}|"); - expect(calls[1].split('\n').join('|')).toBe("-L|codeman|capture-pane|-p|-e|-J|-S|-500|-E|-1|-t|=codeman-it's:|"); - expect(calls[2].split('\n').join('|')).toBe("-L|codeman|capture-pane|-p|-e|-J|-t|=codeman-it's:|"); + expect(calls[0].split('\n').join('|')).toBe( + [ + "-L|codeman|capture-pane|-p|-e|-J|-S|-500|-E|-1|-t|=codeman-it's:", + `display-message|-p|-t|=codeman-it's:|${MARKER}`, + "display-message|-p|-t|=codeman-it's:|#{history_size}", + "capture-pane|-p|-e|-J|-t|=codeman-it's:|", + ].join('|;|') + ); + }); + + it('prints both NULs when tmux fails, so a missing session reads as the script having run', () => { + const failing = mkdtempSync(join(tmpdir(), 'remote-scrollback-fail-')); + try { + writeFileSync(join(failing, 'tmux'), '#!/bin/sh\necho "can\'t find session" >&2\nexit 1\n'); + chmodSync(join(failing, 'tmux'), 0o755); + const stdout = execFileSync('/bin/sh', ['-c', buildRemoteScrollbackScript('codeman', 'gone', 500, 0, MARKER)], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + env: { ...process.env, PATH: `${failing}:${process.env.PATH ?? ''}` }, + }); + expect(stdout).toBe('\x00\x00'); + } finally { + rmSync(failing, { recursive: true, force: true }); + } }); }); @@ -253,7 +344,10 @@ describe('joinScrollbackAndFrame with the remote screen', () => { }); describe('fetchRemoteScrollback', () => { - const OK = '\x002\n\x00older row\nnewer row\n\x00screen row\n'; + /** The marker a read put in its command line. */ + const markerIn = (command: string) => /codeman-history-[0-9a-f]{16}/.exec(command)?.[0] ?? 'no-marker'; + /** A successful read's stdout, answering with the marker the command carries. */ + const OK = async (command: string) => `\x00older row\nnewer row\n${markerIn(command)}\n2\nscreen row\n\x00`; const READ = { scrollback: 'older row\nnewer row\n', screen: 'screen row\n' }; /** An exec-shaped rejection: `code` is the exit status, `killed` a timeout kill. */ const execError = (fields: { code?: number | string; killed?: boolean }) => @@ -274,12 +368,15 @@ describe('fetchRemoteScrollback', () => { }); it('returns the remote rows, running the built command with the round-trip bounds', async () => { - const run = vi.fn(async () => OK); + const run = vi.fn(OK); expect(await fetchRemoteScrollback(remote, SESSION_ID, 100, 1024, run)).toEqual(READ); - expect(run).toHaveBeenCalledWith(buildRemoteScrollbackCommand(remote, SESSION_ID, 100, 1024), { + const marker = markerIn(run.mock.calls[0][0]); + const tailBytes = 1024 + 1024 * 1024; + expect(run).toHaveBeenCalledWith(buildRemoteScrollbackCommand(remote, SESSION_ID, 100, tailBytes, marker), { timeout: 5000, - // The byte cap covers the history; the slack covers the screen after it. - maxBuffer: 1024 + 1024 * 1024, + // The tail bound covers the whole tmux output; the slack covers the NULs + // and anything printed before the script. + maxBuffer: tailBytes + 64 * 1024, }); }); @@ -294,7 +391,7 @@ describe('fetchRemoteScrollback', () => { resetRemoteScrollbackBackoff(); expect(await fetchRemoteScrollback(remote, SESSION_ID, 100, 1024, vi.fn(failure))).toBeNull(); // Another session on the same host is skipped without a round trip. - const next = vi.fn(async () => OK); + const next = vi.fn(OK); expect(await fetchRemoteScrollback(remote, 'other-session', 100, 1024, next)).toBeNull(); expect(next).not.toHaveBeenCalled(); } @@ -303,19 +400,19 @@ describe('fetchRemoteScrollback', () => { it('a remote command failure (tmux exit 1: the session is gone) is that session only, no back-off', async () => { const gone = vi.fn(async () => Promise.reject(execError({ code: 1 }))); expect(await fetchRemoteScrollback(remote, SESSION_ID, 100, 1024, gone)).toBeNull(); - const next = vi.fn(async () => OK); + const next = vi.fn(OK); expect(await fetchRemoteScrollback(remote, 'other-session', 100, 1024, next)).toEqual(READ); expect(next).toHaveBeenCalledOnce(); }); - // Found live against a real host: the `| tail -c` pipe takes tail's exit - // status, so a missing session comes back as exit 0 with both markers and + // Found live against a real host: a missing session comes back as exit 0 + // (the script's closing `printf` hides tmux's status) with both NULs and // nothing between them. The script ran on the host; only this session is // gone, so the host must not be backed off. - it('a missing session hidden behind the tail pipe (exit 0, empty markers) is that session only', async () => { + it('a missing session hidden behind the closing printf (exit 0, empty NULs) is that session only', async () => { const masked = vi.fn(async () => '\x00\x00'); expect(await fetchRemoteScrollback(remote, SESSION_ID, 100, 1024, masked)).toBeNull(); - const next = vi.fn(async () => OK); + const next = vi.fn(OK); expect(await fetchRemoteScrollback(remote, 'other-session', 100, 1024, next)).toEqual(READ); expect(next).toHaveBeenCalledOnce(); }); @@ -323,7 +420,7 @@ describe('fetchRemoteScrollback', () => { it('the back-off ends after 30 s', async () => { vi.useFakeTimers(); await fetchRemoteScrollback(remote, SESSION_ID, 100, 1024, async () => Promise.reject(execError({ code: 255 }))); - const next = vi.fn(async () => OK); + const next = vi.fn(OK); vi.advanceTimersByTime(29_999); expect(await fetchRemoteScrollback(remote, SESSION_ID, 100, 1024, next)).toBeNull(); vi.advanceTimersByTime(1); @@ -342,7 +439,7 @@ describe('fetchRemoteScrollback', () => { const holders = gates.map((gate) => runWithRemoteSshLimit(() => gate.promise)); await Promise.resolve(); - const run = vi.fn(async () => OK); + const run = vi.fn(OK); const pending = fetchRemoteScrollback(remote, SESSION_ID, 100, 1024, run); await vi.advanceTimersByTimeAsync(1499); expect(getQueuedRemoteSshCount()).toBe(1); From 8d92e577f873f6b78244eda75352113f4b56d938 Mon Sep 17 00:00:00 2001 From: timkjr Date: Sun, 11 Oct 2026 18:54:20 -0500 Subject: [PATCH 3/3] fix(remote): accept the history marker glued to a row on tmux before 3.2 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\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 `\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) --- docs/remote-sessions.md | 11 +++++- src/remote-pane-history.ts | 46 ++++++++++++----------- src/tmux-manager.ts | 6 +++ test/remote-pane-history.test.ts | 63 ++++++++++++++++++++++++-------- 4 files changed, 88 insertions(+), 38 deletions(-) diff --git a/docs/remote-sessions.md b/docs/remote-sessions.md index f050fed4..8b9587a3 100644 --- a/docs/remote-sessions.md +++ b/docs/remote-sessions.md @@ -290,7 +290,11 @@ would REPEAT the rows between it and the true one, so a shorter overlap (a lone `$` row that happens to match after a spinner row changed) is not trusted. When the frame cannot be placed (more output scrolled than the screen held, or nothing matches), the join is history over frame, as without the screen read. -What is left: when the screen repeats a whole block of rows, the frame can be +That includes an agent TUI mid-turn (Claude Code, codex, opencode): it keeps a +live region (spinner, composer, footer) pinned to the bottom and inserts new +rows above it, so the frame is not a shifted copy of the screen and is not +placed. A plain shell places. What is left: 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. The history, the history size and the screen are ONE tmux command list @@ -302,7 +306,10 @@ those milliseconds were in neither capture (measured with output streaming: 1-2 rows per load on a LAN host, every read on a local busy pane). This rests on how tmux's command queue behaves, not on a documented guarantee. A random marker row (`display-message`) separates the history from the size and the screen, since -tmux output cannot carry the NUL markers around it. The remote `tail -c` bound +tmux output cannot carry the NUL markers around it. tmux before 3.2 prints no +newline after `capture-pane -p` output, 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. The remote `tail -c` bound applies to that whole output, history first, and is the configured byte cap plus 1 MiB for the marker, size and screen, so a cut lands in the history; the route trims what it returns to the cap itself. diff --git a/src/remote-pane-history.ts b/src/remote-pane-history.ts index 5f161744..f4a12e48 100644 --- a/src/remote-pane-history.ts +++ b/src/remote-pane-history.ts @@ -28,6 +28,8 @@ * rows). This rests on how tmux's command queue behaves, not on a documented * guarantee. tmux output cannot carry the NUL markers, so a random marker line * printed by `display-message` separates the history from the size and screen. + * tmux before 3.2 prints no newline after `capture-pane -p`, so the marker can be + * glued onto an unterminated last history row; the parser accepts it there too. * * Bounded and fail-soft. Any failure (unreachable host, auth, missing session, odd * output) returns null and the caller keeps the local capture, so a remote load is @@ -156,16 +158,6 @@ export function buildRemoteScrollbackCommand( return ['exec', ...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(script)].join(' '); } -/** - * A `tail -c` cut lands mid-row (possibly mid-escape) whenever it bit, so the - * first, partial row is dropped. Under the bound, the rows are whole. - */ -export function dropPartialFirstRow(scrollback: string, maxBytes: number): string { - if (maxBytes <= 0 || Buffer.byteLength(scrollback, 'utf-8') < maxBytes) return scrollback; - const newline = scrollback.indexOf('\n'); - return newline === -1 ? '' : scrollback.slice(newline + 1); -} - /** What one round trip read from the remote pane: its scrollback rows, then its screen. */ export interface RemoteScrollback { /** History rows above the screen, oldest first; '' when the pane has none. */ @@ -177,7 +169,9 @@ export interface RemoteScrollback { /** * The scrollback and screen rows from the script's stdout, or null when the * output is not the script's or tmux printed nothing usable (no such session, - * or a byte cap so small it cut into the screen). + * or a byte cap so small it cut into the screen). The marker may end an + * unterminated history row (tmux before 3.2); when the output reached + * `tailBytes`, the history's partial first row is dropped. */ export function parseRemoteScrollbackOutput( stdout: string, @@ -188,23 +182,33 @@ export function parseRemoteScrollbackOutput( if (start === -1) return null; const end = stdout.indexOf('\0', start + 1); if (end === -1) return null; - // The marker is a whole row of its own, after the history's last row. + // The marker ends a row, after the history's last row. Usually it is a row of + // its own, but tmux before 3.2 prints no newline after `capture-pane -p`, so + // when the history ends inside a wrapped line (`-J` leaves it unterminated) + // the marker is glued onto that row. It is random per read, so no row the + // pane really holds ends with it. const markerRow = `${marker}\n`; const raw = stdout.slice(start + 1, end); - // A cut that landed exactly on the marker row left it whole: there is no - // partial history row to drop, and dropping the first row would lose it. - const output = raw.startsWith(markerRow) ? raw : dropPartialFirstRow(raw, tailBytes); - const at = output.startsWith(markerRow) ? 0 : output.indexOf(`\n${markerRow}`) + 1; - if (at === 0 && !output.startsWith(markerRow)) return null; + const at = raw.indexOf(markerRow); + if (at === -1) return null; const sizeStart = at + markerRow.length; - const sizeEnd = output.indexOf('\n', sizeStart); + const sizeEnd = raw.indexOf('\n', sizeStart); if (sizeEnd === -1) return null; - const sizeText = output.slice(sizeStart, sizeEnd).trim(); + const sizeText = raw.slice(sizeStart, sizeEnd).trim(); if (!/^\d+$/.test(sizeText)) return null; - const screen = output.slice(sizeEnd + 1); + const screen = raw.slice(sizeEnd + 1); // With no history at all, tmux clamps `-E -1` to the first visible row. if (Number(sizeText) === 0) return { scrollback: '', screen }; - return { scrollback: output.slice(0, at), screen }; + // A tail -c cut lands in the history (the bound leaves room for the rest), + // mid-row and possibly mid-escape, so the first, partial row is dropped. A + // cut exactly on the marker row leaves no history at all. + let scrollback = raw.slice(0, at); + if (tailBytes > 0 && Buffer.byteLength(raw, 'utf-8') >= tailBytes) { + const newline = scrollback.indexOf('\n'); + scrollback = newline === -1 ? '' : scrollback.slice(newline + 1); + } + if (scrollback !== '' && !scrollback.endsWith('\n')) scrollback += '\n'; + return { scrollback, screen }; } /** Runs the ssh command line and resolves its stdout; rejects like `exec` does. */ diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index e409be7b..ab8d3859 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -732,6 +732,12 @@ function capturedRowText(row: string): string { * above it matches. A row that changed between the two reads (a spinner, a * clock) fails the true placement; the minimum keeps a lower one from being * taken on a row or two that happen to be equal. + * + * An agent TUI mid-turn (Claude Code, codex, opencode) is not placed: it keeps + * a live region (spinner, composer, footer) pinned to the bottom and inserts new + * rows above it, so the frame is not a shifted copy of the screen and the + * comparison down to the screen's last content row fails. The caller then falls + * back to the plain join. A plain shell places. */ export function findFrameInScreen(screenRows: string[], frameRows: string[]): number { const screen = screenRows.map(capturedRowText); diff --git a/test/remote-pane-history.test.ts b/test/remote-pane-history.test.ts index 33629f33..b8339f62 100644 --- a/test/remote-pane-history.test.ts +++ b/test/remote-pane-history.test.ts @@ -17,7 +17,6 @@ import { tmpdir } from 'node:os'; import { buildRemoteScrollbackCommand, buildRemoteScrollbackScript, - dropPartialFirstRow, fetchRemoteScrollback, newScrollbackMarker, parseRemoteScrollbackOutput, @@ -98,15 +97,6 @@ describe('newScrollbackMarker', () => { }); }); -describe('dropPartialFirstRow', () => { - it('drops the row a tail -c cut landed in, and only then', () => { - expect(dropPartialFirstRow('ow 1\nrow 2\n', 11)).toBe('row 2\n'); - expect(dropPartialFirstRow('row 1\nrow 2\n', 100)).toBe('row 1\nrow 2\n'); - expect(dropPartialFirstRow('no newline at all', 17)).toBe(''); - expect(dropPartialFirstRow('row 1\nrow 2\n', 0)).toBe('row 1\nrow 2\n'); - }); -}); - describe('parseRemoteScrollbackOutput', () => { const read = (tmuxOutput: string) => `\x00${tmuxOutput}\x00`; @@ -138,6 +128,14 @@ describe('parseRemoteScrollbackOutput', () => { }); }); + it('a history under the byte cap keeps every row', () => { + const output = `row 1\nrow 2\n${MARKER}\n7\nscreen\n`; + expect(parseRemoteScrollbackOutput(read(output), MARKER, Buffer.byteLength(output) + 1)).toEqual({ + scrollback: 'row 1\nrow 2\n', + screen: 'screen\n', + }); + }); + it('a cut that landed exactly on the marker row keeps it', () => { const output = `${MARKER}\n3\nscreen\n`; expect(parseRemoteScrollbackOutput(read(output), MARKER, Buffer.byteLength(output))).toEqual({ @@ -146,10 +144,31 @@ describe('parseRemoteScrollbackOutput', () => { }); }); - it('finds the marker only as a whole row', () => { - const output = `echo ${MARKER}\n${MARKER}\n2\nscreen\n`; + it('tmux before 3.2: a marker glued onto an unterminated wrapped row ends that row', () => { + // No newline after `capture-pane -p` output before 3.2, and -J leaves the + // history's last row unterminated when the screen starts mid-line. + const output = `row 1\nfirst half of long row${MARKER}\n31\nsecond half\n$ `; expect(parseRemoteScrollbackOutput(read(output), MARKER, 0)).toEqual({ - scrollback: `echo ${MARKER}\n`, + scrollback: 'row 1\nfirst half of long row\n', + screen: 'second half\n$ ', + }); + }); + + it('tmux before 3.2: a cut history with the glued marker drops only the partial first row', () => { + const output = `ow 1\nrow 2${MARKER}\n7\nscreen\n`; + expect(parseRemoteScrollbackOutput(read(output), MARKER, Buffer.byteLength(output))).toEqual({ + scrollback: 'row 2\n', + screen: 'screen\n', + }); + // No history: the clamped first visible row the marker is glued to is not kept. + expect(parseRemoteScrollbackOutput(read(`$ prompt${MARKER}\n0\n$ prompt\n`), MARKER, 0)).toEqual({ + scrollback: '', + screen: '$ prompt\n', + }); + // The cut landed in the only history row, the one the marker is glued to. + const single = `alf of long row${MARKER}\n7\nscreen\n`; + expect(parseRemoteScrollbackOutput(read(single), MARKER, Buffer.byteLength(single))).toEqual({ + scrollback: '', screen: 'screen\n', }); }); @@ -178,7 +197,8 @@ describe('the remote script under a real shell', () => { callsFile = join(dir, 'calls'); // Stub tmux: logs its argv (one per line) and runs a `;`-separated command // list in order, as tmux does: the history size, the marker it was given, - // two history rows, one screen row. + // two history rows, one screen row. With PRE32 set it prints like tmux + // before 3.2 with the screen starting mid-line: no newline after the history. writeFileSync( join(dir, 'tmux'), [ @@ -189,7 +209,7 @@ describe('the remote script under a real shell', () => { ' case " $1 " in', ' *" #{history_size} "*) echo 2 ;;', ` *" display-message "*) printf '%s\\n' "$2" ;;`, - ' *" -E -1 "*) printf "older row\\nnewer row\\n" ;;', + ' *" -E -1 "*) if [ -n "$PRE32" ]; then printf "older row\\nfirst half"; else printf "older row\\nnewer row\\n"; fi ;;', ' *" capture-pane "*) printf "screen row\\n" ;;', ' esac', '}', @@ -233,6 +253,19 @@ describe('the remote script under a real shell', () => { ); }); + it('tmux before 3.2: an unterminated last history row still parses, re-terminated', () => { + const script = buildRemoteScrollbackScript('codeman', 'old-host', 500, 0, MARKER); + const stdout = execFileSync('/bin/sh', ['-c', script], { + encoding: 'utf-8', + env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}`, PRE32: '1' }, + }); + expect(stdout).toContain(`first half${MARKER}\n`); + expect(parseRemoteScrollbackOutput(stdout, MARKER, 0)).toEqual({ + scrollback: 'older row\nfirst half\n', + screen: 'screen row\n', + }); + }); + it('prints both NULs when tmux fails, so a missing session reads as the script having run', () => { const failing = mkdtempSync(join(tmpdir(), 'remote-scrollback-fail-')); try {