mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
fix(remote): load a remote session's scrollback from the remote tmux
A remote SSH session's local pane runs `ssh -t … tmux new-session -A` (or
`attach`), so the local tmux only sees the remote tmux client's full-screen
repaints and keeps a fraction of the history: measured 48 of 279 lines while
output streamed, none after a reattach. The remote pane, where the shell
runs, keeps all of it.
A full-history load (`GET /api/sessions/:id/terminal?full=1`) of a remote
session now reads the scrollback rows from the remote pane in one ssh round
trip (`#{history_size}` + `capture-pane -S -<N> -E -1`, exact `=name:`
target, newest bytes kept by a remote `tail -c`), and the local capture
reads only its visible frame (`-S 0`), spliced below them
(`PaneCaptureOptions.scrollbackOverride`). The local frame and caret are
kept because the live stream keeps drawing into the local pane.
Fail-soft and bounded: connection options from buildSshConnectionArgs(),
the shared remote-ssh limiter (a slot waited for longer than 1.5 s is given
up), a 5 s timeout, and a 30 s per-host back-off after any failure, which
keeps the local capture as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa3730aace
commit
b33d6da58a
7 files changed
+449
-2
No files matched your search
@@ -251,6 +251,18 @@ unreachable host answers "unknown", which also means do not revive. The answer
|
|||||||
is cached per session and cleared whenever the pane is next seen alive, so a
|
is cached per session and cleared whenever the pane is next seen alive, so a
|
||||||
stale `true` from one transport drop can never revive the NEXT clean exit.
|
stale `true` from one transport drop can never revive the NEXT clean exit.
|
||||||
|
|
||||||
|
## Terminal history from the remote tmux
|
||||||
|
|
||||||
|
The local pane runs the ssh attach, so it only sees the remote tmux client's
|
||||||
|
full-screen repaints and keeps a fraction of the scrollback (none after a
|
||||||
|
reattach). A full-history load (`GET /api/sessions/:id/terminal?full=1`) of a
|
||||||
|
remote session therefore reads the scrollback rows from the REMOTE pane in one
|
||||||
|
ssh round trip (`src/remote-pane-history.ts`) and splices them above the local
|
||||||
|
visible frame and caret. Bounded (5 s, the shared ssh limiter) and fail-soft:
|
||||||
|
any failure keeps the local capture and skips that host for 30 s. 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).
|
||||||
|
|
||||||
## File access over SSH
|
## File access over SSH
|
||||||
|
|
||||||
A remote case's `workingDir` is an absolute path on the **remote** host
|
A remote case's `workingDir` is an absolute path on the **remote** host
|
||||||
|
|||||||
@@ -189,6 +189,13 @@ export interface PaneCaptureOptions {
|
|||||||
fullHistory?: boolean;
|
fullHistory?: boolean;
|
||||||
/** Bound the full-history capture to this many scrollback lines (`-S -<N>`). */
|
/** Bound the full-history capture to this many scrollback lines (`-S -<N>`). */
|
||||||
historyLimitLines?: number;
|
historyLimitLines?: number;
|
||||||
|
/**
|
||||||
|
* Full-history only: scrollback rows read from somewhere other than this pane
|
||||||
|
* (raw `capture-pane -p -e -J` text, oldest first). The pane then contributes
|
||||||
|
* only its visible frame and caret, and its own history is not read. A remote
|
||||||
|
* session's real history lives in the REMOTE tmux (`remote-pane-history.ts`).
|
||||||
|
*/
|
||||||
|
scrollbackOverride?: string;
|
||||||
/**
|
/**
|
||||||
* Byte cap the consumer will keep from the capture. Sizes the child-process
|
* 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
|
* stdout buffer (with slack) so multi-MB scrollback dumps aren't killed by
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Scrollback of a remote SSH session, read from the REMOTE tmux.
|
||||||
|
*
|
||||||
|
* A remote session's local pane runs `ssh -t … tmux new-session -A` (or `attach`),
|
||||||
|
* so the local tmux only ever sees what the remote tmux CLIENT draws: full-screen
|
||||||
|
* repaints, not scrolling output. The local pane therefore keeps a fraction of the
|
||||||
|
* history (measured: 48 of 279 lines while output streamed, none after a reattach),
|
||||||
|
* while the remote pane, where the shell really runs, keeps all of it. A full-history
|
||||||
|
* load of a remote session reads the scrollback from there instead, and the caller
|
||||||
|
* splices it above the LOCAL visible frame (`PaneCaptureOptions.scrollbackOverride`):
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Bounded and fail-soft. Any failure (unreachable host, auth, missing session, odd
|
||||||
|
* output) returns null and the caller keeps the local capture, so a remote load is
|
||||||
|
* never worse than before. A failure also starts a short per-host back-off, so a
|
||||||
|
* host that is asleep costs one timeout, not one per tab switch. The child runs under
|
||||||
|
* the shared remote-ssh limiter (`remote-ssh-limiter.ts`) like every other short-lived
|
||||||
|
* remote ssh, but gives up on a slot it waited too long for, and the connection
|
||||||
|
* options come from `buildSshConnectionArgs()`.
|
||||||
|
*
|
||||||
|
* How much history exists is the REMOTE tmux's `history-limit` (2000 lines unless
|
||||||
|
* the remote user's tmux.conf raises it); the launch command does not set it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { exec } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import type { SessionRemote } from './types/session.js';
|
||||||
|
import { buildSshConnectionArgs, remoteSshTarget, shellescape } from './remote-hosts.js';
|
||||||
|
import { runWithRemoteSshLimit } from './remote-ssh-limiter.js';
|
||||||
|
import { remoteTmuxLocation } from './tmux-manager.js';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bound on the whole round trip. A full load waits on it, so it is far below the
|
||||||
|
* 10 s `ConnectTimeout` the shared ssh options carry: past this the local capture
|
||||||
|
* is the better answer.
|
||||||
|
*/
|
||||||
|
const REMOTE_HISTORY_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Longest a load waits for a slot in the shared ssh pool, which file previews and
|
||||||
|
* attachment probes (20 s each) also hold. Past it the local capture is used.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** Read ceiling when the configured byte cap is 0 (unbounded) and no `tail -c` runs. */
|
||||||
|
const UNBOUNDED_READ_MAX_BYTES = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host id → time the back-off ends. Keyed by HOST, so a sleeping host with five
|
||||||
|
* sessions costs one timeout, and the map is bounded by the configured hosts.
|
||||||
|
*/
|
||||||
|
const backoffUntil = new Map<string, number>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function buildRemoteScrollbackScript(
|
||||||
|
socket: string,
|
||||||
|
sessionName: string,
|
||||||
|
historyLines: number,
|
||||||
|
maxBytes: number
|
||||||
|
): 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.
|
||||||
|
const target = shellescape(`=${sessionName}:`);
|
||||||
|
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)}` : '';
|
||||||
|
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}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full ssh command line for {@link buildRemoteScrollbackScript}. */
|
||||||
|
export function buildRemoteScrollbackCommand(
|
||||||
|
remote: SessionRemote,
|
||||||
|
sessionId: string,
|
||||||
|
historyLines: number,
|
||||||
|
maxBytes: number
|
||||||
|
): string {
|
||||||
|
const { socket, sessionName } = remoteTmuxLocation(remote, sessionId);
|
||||||
|
const script = buildRemoteScrollbackScript(socket, sessionName, historyLines, maxBytes);
|
||||||
|
// `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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scrollback rows from the script's stdout, '' when the pane has no history,
|
||||||
|
* or null when the output is not the script's.
|
||||||
|
*/
|
||||||
|
export function parseRemoteScrollbackOutput(stdout: string): string | null {
|
||||||
|
const start = stdout.indexOf('\0');
|
||||||
|
if (start === -1) return null;
|
||||||
|
const sizeEnd = stdout.indexOf('\0', start + 1);
|
||||||
|
if (sizeEnd === -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a remote session's scrollback from the remote tmux, or null to keep the
|
||||||
|
* local capture. Never throws.
|
||||||
|
*/
|
||||||
|
export async function fetchRemoteScrollback(
|
||||||
|
remote: SessionRemote,
|
||||||
|
sessionId: string,
|
||||||
|
historyLines: number,
|
||||||
|
maxBytes: number
|
||||||
|
): Promise<string | null> {
|
||||||
|
// No real ssh under test (mirrors remote-files.ts / remote-hosts.ts).
|
||||||
|
if (process.env.VITEST) return null;
|
||||||
|
const queuedAt = Date.now();
|
||||||
|
const until = backoffUntil.get(remote.hostId);
|
||||||
|
if (until !== undefined) {
|
||||||
|
if (queuedAt < until) return null;
|
||||||
|
backoffUntil.delete(remote.hostId);
|
||||||
|
}
|
||||||
|
const command = buildRemoteScrollbackCommand(remote, sessionId, historyLines, maxBytes);
|
||||||
|
try {
|
||||||
|
const stdout = await runWithRemoteSshLimit(async () => {
|
||||||
|
// The pool was busy past what a load should wait: give the slot back.
|
||||||
|
if (Date.now() - queuedAt > REMOTE_HISTORY_SLOT_WAIT_MS) return null;
|
||||||
|
const result = await execAsync(command, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: REMOTE_HISTORY_TIMEOUT_MS,
|
||||||
|
maxBuffer: (maxBytes > 0 ? maxBytes : UNBOUNDED_READ_MAX_BYTES) + REMOTE_HISTORY_SLACK_BYTES,
|
||||||
|
});
|
||||||
|
return result.stdout;
|
||||||
|
});
|
||||||
|
if (stdout === null) return null;
|
||||||
|
const scrollback = parseRemoteScrollbackOutput(stdout);
|
||||||
|
if (scrollback === null) {
|
||||||
|
backoffUntil.set(remote.hostId, Date.now() + REMOTE_HISTORY_BACKOFF_MS);
|
||||||
|
console.warn(`[remote-history] ${remote.label}: unexpected output; using the local capture`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return dropPartialFirstRow(scrollback, maxBytes);
|
||||||
|
} catch (err) {
|
||||||
|
backoffUntil.set(remote.hostId, Date.now() + REMOTE_HISTORY_BACKOFF_MS);
|
||||||
|
const e = err as { killed?: boolean; code?: unknown };
|
||||||
|
const reason = e.killed ? 'timed out' : typeof e.code === 'number' ? `exit ${e.code}` : 'failed';
|
||||||
|
console.warn(`[remote-history] ${remote.label}: remote scrollback read ${reason}; using the local capture`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test hook: forget every host back-off. */
|
||||||
|
export function resetRemoteScrollbackBackoff(): void {
|
||||||
|
backoffUntil.clear();
|
||||||
|
}
|
||||||
+35
-2
@@ -694,6 +694,16 @@ export function normalizeScrollbackEol(buffer: string): string {
|
|||||||
return buffer.replace(/\r?\n/g, '\r\n');
|
return buffer.replace(/\r?\n/g, '\r\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function joinScrollbackAndFrame(scrollback: string | undefined, frame: string): string {
|
||||||
|
if (!scrollback) return frame;
|
||||||
|
return `${scrollback.replace(/\n$/, '')}\n${frame}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Pane geometry and caret position, as `display-message` reports them. */
|
/** Pane geometry and caret position, as `display-message` reports them. */
|
||||||
interface PaneCursorGeometry {
|
interface PaneCursorGeometry {
|
||||||
cols: number;
|
cols: number;
|
||||||
@@ -962,6 +972,19 @@ export function remoteTmuxSessionName(sessionId: string): string {
|
|||||||
return `codeman-ssh-${sessionId.slice(0, 8)}`;
|
return `codeman-ssh-${sessionId.slice(0, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The remote tmux socket and session a remote Codeman session's pane is attached
|
||||||
|
* to, matching the command that attached it: an owned session is the one
|
||||||
|
* `buildRemoteLaunchCommand` created on the private socket, a non-owned one is the
|
||||||
|
* discovered session `buildRemoteAttachCommand` joined on `-L codeman`.
|
||||||
|
*/
|
||||||
|
export function remoteTmuxLocation(remote: SessionRemote, sessionId: string): { socket: string; sessionName: string } {
|
||||||
|
if (remote.owned === false) {
|
||||||
|
return { socket: 'codeman', sessionName: remote.remoteSessionName || remoteTmuxSessionName(sessionId) };
|
||||||
|
}
|
||||||
|
return { socket: REMOTE_TMUX_SOCKET, sessionName: remoteTmuxSessionName(sessionId) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* COD-104 — build the SSH command that launches (or reattaches) a remote
|
* COD-104 — build the SSH command that launches (or reattaches) a remote
|
||||||
* session INSIDE a tmux server on the remote host, so the remote agent survives
|
* session INSIDE a tmux server on the remote host, so the remote agent survives
|
||||||
@@ -3870,7 +3893,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
typeof requestedLines === 'number' && Number.isFinite(requestedLines) && requestedLines > 0
|
typeof requestedLines === 'number' && Number.isFinite(requestedLines) && requestedLines > 0
|
||||||
? Math.trunc(requestedLines)
|
? Math.trunc(requestedLines)
|
||||||
: DEFAULT_TMUX_HISTORY_LIMIT;
|
: DEFAULT_TMUX_HISTORY_LIMIT;
|
||||||
const captureFlags = fullHistory ? `capture-pane -p -e -J -S -${historyLines}` : 'capture-pane -p -e';
|
// With a scrollback override this pane's own history is not read at all:
|
||||||
|
// `-S 0` starts at the top of the visible frame.
|
||||||
|
const scrollbackOverride = fullHistory ? opts?.scrollbackOverride : undefined;
|
||||||
|
const captureFlags = !fullHistory
|
||||||
|
? 'capture-pane -p -e'
|
||||||
|
: scrollbackOverride !== undefined
|
||||||
|
? 'capture-pane -p -e -J -S 0'
|
||||||
|
: `capture-pane -p -e -J -S -${historyLines}`;
|
||||||
// execSync's default maxBuffer (1MB) kills multi-MB scrollback dumps
|
// execSync's default maxBuffer (1MB) kills multi-MB scrollback dumps
|
||||||
// (ENOBUFS) and would silently degrade full-history capture to the byte
|
// (ENOBUFS) and would silently degrade full-history capture to the byte
|
||||||
// buffer for exactly the long sessions it exists for — size it from the
|
// buffer for exactly the long sessions it exists for — size it from the
|
||||||
@@ -3883,7 +3913,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
execOpts.maxBuffer =
|
execOpts.maxBuffer =
|
||||||
(opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES;
|
(opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES;
|
||||||
}
|
}
|
||||||
const rawCapture = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts);
|
const rawCapture = joinScrollbackAndFrame(
|
||||||
|
scrollbackOverride,
|
||||||
|
execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts)
|
||||||
|
);
|
||||||
// Query the cursor BEFORE deciding anything else. On the full-history path
|
// 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
|
// it settles both how the capture is trimmed and whether a cursor move is
|
||||||
// appended, and those two have to agree: trailing blank rows are only safe
|
// appended, and those two have to agree: trailing blank rows are only safe
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ import {
|
|||||||
toAttachedSessionRemote,
|
toAttachedSessionRemote,
|
||||||
toSessionRemote,
|
toSessionRemote,
|
||||||
} from '../../remote-hosts.js';
|
} from '../../remote-hosts.js';
|
||||||
|
import { fetchRemoteScrollback } from '../../remote-pane-history.js';
|
||||||
import {
|
import {
|
||||||
checkDockerAvailable,
|
checkDockerAvailable,
|
||||||
checkDockerConfigDrift,
|
checkDockerConfigDrift,
|
||||||
@@ -3116,6 +3117,19 @@ export function registerSessionRoutes(
|
|||||||
maxCaptureBytes: terminalBufferMaxBytes,
|
maxCaptureBytes: terminalBufferMaxBytes,
|
||||||
}
|
}
|
||||||
: {};
|
: {};
|
||||||
|
// A remote session's local pane only sees the remote tmux client's repaints,
|
||||||
|
// so its own scrollback is a fraction of the real one: read that from the
|
||||||
|
// remote tmux and splice it above the local frame. Null (host unreachable,
|
||||||
|
// no remote session, back-off) keeps the local capture as before.
|
||||||
|
if (isFullReload && session.remote && muxName && captureOpts.historyLimitLines !== undefined) {
|
||||||
|
const remoteScrollback = await fetchRemoteScrollback(
|
||||||
|
session.remote,
|
||||||
|
session.id,
|
||||||
|
captureOpts.historyLimitLines,
|
||||||
|
terminalBufferMaxBytes
|
||||||
|
);
|
||||||
|
if (remoteScrollback !== null) captureOpts.scrollbackOverride = remoteScrollback;
|
||||||
|
}
|
||||||
const liveMuxBuffer =
|
const liveMuxBuffer =
|
||||||
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
||||||
? ctx.mux.captureActivePaneBuffer(muxName, captureOpts)
|
? ctx.mux.captureActivePaneBuffer(muxName, captureOpts)
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for reading a remote session's scrollback from the remote
|
||||||
|
* tmux (`src/remote-pane-history.ts`) and splicing it above the local frame.
|
||||||
|
*
|
||||||
|
* The remote script is also run by a real `/bin/sh`, with a stub `tmux` first on
|
||||||
|
* PATH: the script IS the remote command, so `sh -c <script>` is what sshd runs on
|
||||||
|
* the other end, and quoting mistakes show up there and nowhere else.
|
||||||
|
*
|
||||||
|
* Port: N/A (no HTTP server).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import {
|
||||||
|
buildRemoteScrollbackCommand,
|
||||||
|
buildRemoteScrollbackScript,
|
||||||
|
dropPartialFirstRow,
|
||||||
|
fetchRemoteScrollback,
|
||||||
|
parseRemoteScrollbackOutput,
|
||||||
|
} from '../src/remote-pane-history.js';
|
||||||
|
import { joinScrollbackAndFrame, remoteTmuxLocation } from '../src/tmux-manager.js';
|
||||||
|
import type { SessionRemote } from '../src/types/session.js';
|
||||||
|
|
||||||
|
const SESSION_ID = '663e3eae-8da6-4c7f-9231-611eb2840641';
|
||||||
|
const remote: SessionRemote = {
|
||||||
|
hostId: 'h1',
|
||||||
|
label: 'proxx',
|
||||||
|
host: 'proxx.lan',
|
||||||
|
username: 'tim',
|
||||||
|
port: 2222,
|
||||||
|
remotePath: '/home/tim',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('remoteTmuxLocation', () => {
|
||||||
|
it('an owned session is the launch command’s session on the private socket', () => {
|
||||||
|
expect(remoteTmuxLocation(remote, SESSION_ID)).toEqual({
|
||||||
|
socket: 'codeman-remote',
|
||||||
|
sessionName: 'codeman-ssh-663e3eae',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a discovered session is the one the attach command joined on -L codeman', () => {
|
||||||
|
const discovered = { ...remote, owned: false, remoteSessionName: 'codeman-disco1' };
|
||||||
|
expect(remoteTmuxLocation(discovered, SESSION_ID)).toEqual({ socket: 'codeman', sessionName: 'codeman-disco1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildRemoteScrollbackCommand', () => {
|
||||||
|
it('goes through the shared ssh options and reads history rows only', () => {
|
||||||
|
const command = buildRemoteScrollbackCommand(remote, SESSION_ID, 10000, 2048);
|
||||||
|
expect(command.startsWith('exec ssh -o BatchMode=yes')).toBe(true);
|
||||||
|
expect(command).toContain('-p 2222');
|
||||||
|
expect(command).toContain('tim@proxx.lan');
|
||||||
|
// Socket and target are shellescaped inside the (itself shellescaped) script.
|
||||||
|
expect(command).toContain('codeman-remote');
|
||||||
|
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('| tail -c 2048');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a byte cap of 0 (unbounded) runs no tail', () => {
|
||||||
|
expect(buildRemoteScrollbackCommand(remote, SESSION_ID, 10000, 0)).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('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');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('');
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the remote script under a real shell', () => {
|
||||||
|
let dir: string;
|
||||||
|
let callsFile: string;
|
||||||
|
|
||||||
|
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.
|
||||||
|
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',
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
chmodSync(join(dir, 'tmux'), 0o755);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
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);
|
||||||
|
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');
|
||||||
|
const calls = execFileSync('cat', [callsFile], { encoding: 'utf-8' }).split('--\n').filter(Boolean);
|
||||||
|
expect(calls).toHaveLength(2);
|
||||||
|
// 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:|");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('joinScrollbackAndFrame', () => {
|
||||||
|
it('puts the scrollback rows above the frame in tmux’s own row shape', () => {
|
||||||
|
expect(joinScrollbackAndFrame('old 1\nold 2\n', 'frame 1\nframe 2\n')).toBe('old 1\nold 2\nframe 1\nframe 2\n');
|
||||||
|
expect(joinScrollbackAndFrame('old 1', 'frame 1')).toBe('old 1\nframe 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no scrollback leaves the frame alone', () => {
|
||||||
|
expect(joinScrollbackAndFrame(undefined, 'frame\n')).toBe('frame\n');
|
||||||
|
expect(joinScrollbackAndFrame('', 'frame\n')).toBe('frame\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchRemoteScrollback', () => {
|
||||||
|
it('never opens a connection under test', async () => {
|
||||||
|
expect(await fetchRemoteScrollback(remote, SESSION_ID, 100, 1024)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,6 +56,11 @@ vi.mock('../../src/remote-hosts.js', async (orig) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The remote scrollback read is an ssh round trip; the route only decides when to
|
||||||
|
// ask and what to do with the answer.
|
||||||
|
const remoteScrollback = vi.hoisted(() => vi.fn(async (): Promise<string | null> => null));
|
||||||
|
vi.mock('../../src/remote-pane-history.js', () => ({ fetchRemoteScrollback: remoteScrollback }));
|
||||||
|
|
||||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||||
import { RemoteWakeRegistry, REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS } from '../../src/remote-wake.js';
|
import { RemoteWakeRegistry, REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS } from '../../src/remote-wake.js';
|
||||||
import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.js';
|
import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.js';
|
||||||
@@ -1143,6 +1148,44 @@ describe('session-routes', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a remote session's full capture takes its scrollback from the remote tmux", async () => {
|
||||||
|
const remote = { hostId: 'h1', label: 'proxx', host: 'proxx.lan', username: 'tim', remotePath: '/home/tim' };
|
||||||
|
(harness.ctx._session as unknown as { remote?: unknown }).remote = remote;
|
||||||
|
harness.ctx._session.mode = 'shell';
|
||||||
|
const captureSpy = vi.fn((_name: string, _opts?: { scrollbackOverride?: string }) => 'captured frame');
|
||||||
|
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
|
||||||
|
const load = async (query: string) => {
|
||||||
|
captureSpy.mockClear();
|
||||||
|
remoteScrollback.mockClear();
|
||||||
|
await harness.app.inject({ method: 'GET', url: `/api/sessions/${harness.ctx._sessionId}/terminal?${query}` });
|
||||||
|
return captureSpy.mock.calls[0]?.[1];
|
||||||
|
};
|
||||||
|
|
||||||
|
remoteScrollback.mockResolvedValueOnce('remote line 1\nremote line 2\n');
|
||||||
|
const opts = await load('full=1&lines=500');
|
||||||
|
expect(remoteScrollback).toHaveBeenCalledWith(remote, harness.ctx._sessionId, 500, expect.any(Number));
|
||||||
|
expect(opts).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
fullHistory: true,
|
||||||
|
historyLimitLines: 500,
|
||||||
|
scrollbackOverride: 'remote line 1\nremote line 2\n',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unreachable host / back-off: the local capture, unchanged.
|
||||||
|
remoteScrollback.mockResolvedValueOnce(null);
|
||||||
|
expect(await load('full=1&lines=500')).not.toHaveProperty('scrollbackOverride');
|
||||||
|
|
||||||
|
// A visible-frame load reads no history, so it never asks the remote.
|
||||||
|
await load(`tail=${1024 * 1024}`);
|
||||||
|
expect(remoteScrollback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// A local session never asks either.
|
||||||
|
(harness.ctx._session as unknown as { remote?: unknown }).remote = undefined;
|
||||||
|
await load('full=1');
|
||||||
|
expect(remoteScrollback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('`lines=` leaves a visible-frame capture (no full=1) exactly as it was', async () => {
|
it('`lines=` leaves a visible-frame capture (no full=1) exactly as it was', async () => {
|
||||||
harness.ctx._session.mode = 'shell';
|
harness.ctx._session.mode = 'shell';
|
||||||
const captureSpy = vi.fn(() => 'only the visible frame');
|
const captureSpy = vi.fn(() => 'only the visible frame');
|
||||||
|
|||||||
Reference in new issue
Block a user