Files
Codeman/test/remote-pane-history.test.ts
T
timkjrandClaude Opus 5.5 136f11549b fix(remote): bound the history read's slot wait in the queue, back off only on transport failures
Review follow-ups for the remote scrollback read.

The 1.5 s slot-wait bound was checked inside the task, so it only fired
once runWithRemoteSshLimit() had already handed over a slot, after
everything queued ahead of it. runWithRemoteSshLimit() now takes
`maxWaitMs`: a waiter that times out leaves the queue, rejects with
RemoteSshSlotTimeoutError, never runs and never touches `active`. The
history read uses it, and a slot timeout does not back the host off.

Only a transport failure backs the host off now (ssh exit 255, a
timeout, a spawn error, output that is not the script's), via
classifyRemoteAliveExit(). A remote command failing is about that
session only. A live run against a real host showed a missing session
comes back as exit 0 with empty markers, because the `| tail -c` pipe
takes tail's exit status, so markers that arrived without a size are
also per-session.

fetchRemoteScrollback() takes an optional runner as a test seam; tests
now cover the bounded wait, each back-off class, its expiry, and the
masked missing-session case. A source pin covers the `-S 0` capture
branch. The two seams that can drop rows at the join (rows that scroll
during the round trip, a taller shared client) are documented in
docs/remote-sessions.md, and the limiter's header and the remote-ssh
invariants now name the history read as an ssh consumer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:28:25 -05:00

264 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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, beforeEach, afterEach, vi } 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,
resetRemoteScrollbackBackoff,
} from '../src/remote-pane-history.js';
import {
getActiveRemoteSshCount,
getQueuedRemoteSshCount,
getRemoteSshLimit,
runWithRemoteSshLimit,
} from '../src/remote-ssh-limiter.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', () => {
const OK = '\x002\n\x00older row\nnewer row\n';
const READ = 'older row\nnewer 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);
beforeEach(() => {
resetRemoteScrollbackBackoff();
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('never opens a connection under test without an injected runner', async () => {
expect(await fetchRemoteScrollback(remote, SESSION_ID, 100, 1024)).toBeNull();
});
it('returns the remote rows, running the built command with the round-trip bounds', async () => {
const run = vi.fn(async () => OK);
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,
});
});
it('backs the HOST off after a transport failure: ssh exit 255, a timeout, a spawn error, odd output', async () => {
const failures: Array<() => Promise<string>> = [
async () => Promise.reject(execError({ code: 255 })),
async () => Promise.reject(execError({ killed: true, code: null as unknown as number })),
async () => Promise.reject(execError({ code: 'ENOENT' })),
async () => 'not the script output',
];
for (const failure of failures) {
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);
expect(await fetchRemoteScrollback(remote, 'other-session', 100, 1024, next)).toBeNull();
expect(next).not.toHaveBeenCalled();
}
});
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);
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
// 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 () => {
const masked = vi.fn(async () => '\x00\x00');
expect(await fetchRemoteScrollback(remote, SESSION_ID, 100, 1024, masked)).toBeNull();
const next = vi.fn(async () => OK);
expect(await fetchRemoteScrollback(remote, 'other-session', 100, 1024, next)).toEqual(READ);
expect(next).toHaveBeenCalledOnce();
});
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);
vi.advanceTimersByTime(29_999);
expect(await fetchRemoteScrollback(remote, SESSION_ID, 100, 1024, next)).toBeNull();
vi.advanceTimersByTime(1);
expect(await fetchRemoteScrollback(remote, SESSION_ID, 100, 1024, next)).toEqual(READ);
});
it('gives up after 1.5 s queued behind a busy ssh pool: never runs, no slot leaked, no back-off', async () => {
vi.useFakeTimers();
const gates = Array.from({ length: getRemoteSshLimit() }, () => {
let release!: () => void;
const promise = new Promise<void>((r) => {
release = r;
});
return { promise, release };
});
const holders = gates.map((gate) => runWithRemoteSshLimit(() => gate.promise));
await Promise.resolve();
const run = vi.fn(async () => OK);
const pending = fetchRemoteScrollback(remote, SESSION_ID, 100, 1024, run);
await vi.advanceTimersByTimeAsync(1499);
expect(getQueuedRemoteSshCount()).toBe(1);
await vi.advanceTimersByTimeAsync(1);
expect(await pending).toBeNull();
expect(run).not.toHaveBeenCalled();
expect(getQueuedRemoteSshCount()).toBe(0);
for (const gate of gates) gate.release();
await Promise.all(holders);
expect(getActiveRemoteSshCount()).toBe(0);
// A busy pool said nothing about the host.
expect(await fetchRemoteScrollback(remote, SESSION_ID, 100, 1024, run)).toEqual(READ);
});
});