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', }) );