diff --git a/docs/remote-sessions.md b/docs/remote-sessions.md index 01c7428b..8b9587a3 100644 --- a/docs/remote-sessions.md +++ b/docs/remote-sessions.md @@ -266,18 +266,53 @@ 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, 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 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. +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 +(`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. 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. ## File access over SSH diff --git a/src/mux-interface.ts b/src/mux-interface.ts index d40256fb..b97ff497 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..f4a12e48 100644 --- a/src/remote-pane-history.ts +++ b/src/remote-pane-history.ts @@ -11,9 +11,25 @@ * 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 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. + * 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 @@ -29,6 +45,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'; @@ -53,9 +70,26 @@ 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. */ +/** + * 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 = 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; @@ -66,17 +100,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. @@ -84,11 +129,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)}` : ''; + // 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 -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'` ); } @@ -97,37 +149,66 @@ 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(' '); } -/** - * 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. */ + scrollback: string; + /** The screen's rows, read in the same tmux command list, so they begin where the scrollback ends. */ + 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 or tmux printed nothing usable (no such session, + * 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): string | 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 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); + const at = raw.indexOf(markerRow); + if (at === -1) return null; + const sizeStart = at + markerRow.length; + const sizeEnd = raw.indexOf('\n', sizeStart); if (sizeEnd === -1) return null; - const sizeText = stdout.slice(start + 1, sizeEnd).trim(); + const sizeText = raw.slice(sizeStart, sizeEnd).trim(); if (!/^\d+$/.test(sizeText)) return null; - if (Number(sizeText) === 0) return ''; - return stdout.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 }; + // 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. */ @@ -143,8 +224,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()`. * @@ -157,7 +238,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); @@ -165,32 +246,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 scrollback = parseRemoteScrollbackOutput(stdout); - if (scrollback === 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; + const parsed = parseRemoteScrollbackOutput(stdout, marker, tailBytes); + if (parsed === null) { + // 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 dropPartialFirstRow(scrollback, maxBytes); + return parsed; } 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 c1122547..9778124c 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -694,16 +694,93 @@ 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. + * + * 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); + 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; - // Two separate captures: tmux encodes the frame relative to the DEFAULT - // state, so the scrollback's last row must not leave its attributes active. - return `${scrollback.replace(/\n$/, '')}\x1b[0m\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)); + } + // Separate captures: tmux encodes the frame relative to the DEFAULT state, so + // the rows above it must not leave their attributes active. + return above.length > 0 ? `${above.join('\n')}\x1b[0m\n${frame}` : frame; } /** Pane geometry and caret position, as `display-message` reports them. */ @@ -3933,7 +4010,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 67346530..ccbac0ed 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -3138,7 +3138,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 capture = (opts: PaneCaptureOptions): string | null => muxName && typeof ctx.mux.captureActivePaneBuffer === 'function' diff --git a/test/remote-pane-history.test.ts b/test/remote-pane-history.test.ts index 4b5ca3f8..d7cdb3b6 100644 --- a/test/remote-pane-history.test.ts +++ b/test/remote-pane-history.test.ts @@ -17,9 +17,10 @@ import { tmpdir } from 'node:os'; import { buildRemoteScrollbackCommand, buildRemoteScrollbackScript, - dropPartialFirstRow, fetchRemoteScrollback, + newScrollbackMarker, parseRemoteScrollbackOutput, + remoteTailBytes, resetRemoteScrollbackBackoff, } from '../src/remote-pane-history.js'; import { @@ -28,7 +29,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'; @@ -55,9 +56,11 @@ describe('remoteTmuxLocation', () => { }); }); +const MARKER = 'codeman-history-0123456789abcdef'; + describe('buildRemoteScrollbackCommand', () => { - it('goes through the shared ssh options and reads history rows only', () => { - const command = buildRemoteScrollbackCommand(remote, SESSION_ID, 10000, 2048); + it('goes through the shared ssh options and reads the history rows, then the screen', () => { + 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,37 +69,122 @@ 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'); + 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('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('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); }); }); 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')).toBe('line a\nline b\n'); + expect( + 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', + }); }); 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(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(); - expect(parseRemoteScrollbackOutput('\x00not a number\n\x00rows')).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 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({ + scrollback: '', + screen: 'screen\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: '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', + }); + }); + + 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(); }); }); @@ -107,18 +195,28 @@ 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. 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'), [ '#!/bin/sh', `for a in "$@"; do printf '%s\\n' "$a"; done >> '${callsFile}'`, `printf -- '--\\n' >> '${callsFile}'`, - 'case " $* " in', - ' *" display-message "*) echo 2 ;;', - ' *" capture-pane "*) printf "older row\\nnewer row\\n" ;;', - 'esac', + 'run() {', + ' case " $1 " in', + ' *" #{history_size} "*) echo 2 ;;', + ` *" display-message "*) printf '%s\\n' "$2" ;;`, + ' *" -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', + '}', + "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); @@ -128,19 +226,60 @@ 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 bytes only: "newer row\n". - expect(parseRemoteScrollbackOutput(stdout)).toBe('newer 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(2); + // 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[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('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 { + 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 }); + } }); }); @@ -163,9 +302,95 @@ 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'); + // The rows above the frame end with an SGR reset: the frame is a separate capture. + const reset = (block: string) => `${block.replace(/\n$/, '')}\x1b[0m\n`; + + 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(`${reset(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(`${reset(`${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(`${reset(`${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(`${reset(`${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(`${reset(`${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(`${reset(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(`${reset(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(`${reset(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(`${reset(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('\x1b[0m\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'; + /** 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 }) => Object.assign(new Error('Command failed'), fields); @@ -185,11 +410,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, - maxBuffer: 1024 + 64 * 1024, + // The tail bound covers the whole tmux output; the slack covers the NULs + // and anything printed before the script. + maxBuffer: tailBytes + 64 * 1024, }); }); @@ -204,7 +433,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(); } @@ -213,19 +442,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(); }); @@ -233,7 +462,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); @@ -252,7 +481,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); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 5e29290e..706f0858 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -1208,7 +1208,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( @@ -1216,6 +1219,7 @@ describe('session-routes', () => { fullHistory: true, historyLimitLines: 500, scrollbackOverride: 'remote line 1\nremote line 2\n', + scrollbackOverrideScreen: 'remote screen\n', }) ); @@ -1356,7 +1360,7 @@ describe('session-routes', () => { const session = harness.ctx._session as unknown as { remote?: unknown }; session.remote = { hostId: 'h1', label: 'proxx', host: 'proxx.lan', username: 'tim', remotePath: '/home/tim' }; try { - remoteScrollback.mockResolvedValueOnce('remote line 1\n'); + remoteScrollback.mockResolvedValueOnce({ scrollback: 'remote line 1\n', screen: '' } as never); const spliced = await load(`full=1&tail=${1024 * 1024}&alt=recording&lines=10000`); expect(spy).toHaveBeenCalledTimes(1); expect(spliced.source).toBe('mux-full-history');