mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
136f11549b
commit
28b41efa58
7 files changed
+267
-42
No files matched your search
+25
-9
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+40
-15
@@ -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 -<N> -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 -<N> -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<string | null> {
|
||||
): Promise<RemoteScrollback | null> {
|
||||
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;
|
||||
|
||||
+76
-4
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user