mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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>
This commit is contained in:
1 parent
b33d6da58a
commit
136f11549b
7 files changed
+304
-42
No files matched your search
@@ -77,7 +77,7 @@ OMP's registry entry declares `privilegedParams: []` and a launch spec that only
|
||||
|
||||
Further detail: ⚠️ **Auto-reconnect revives ONLY when the durable remote tmux session is verifiably still alive** (`remoteTmuxSessionAlive()`, a `has-session` probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, `exit`) tears that session down, and `isPaneDead()` cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its `|| --resume` fallback masked it). An unreachable host answers `undefined`, which also means do not revive. ⚠️ `has-session` prints NOTHING on success, so the probe is classified by EXIT STATUS (`classifyRemoteAliveExit`: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale `true` from one transport drop would revive the next clean exit.
|
||||
|
||||
File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk. ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links.
|
||||
File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk. ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links. The third surface is a remote session's full-history load, which reads the scrollback from the remote tmux (`src/remote-pane-history.ts`) through the same limiter, with a bounded slot wait (`maxWaitMs`) since the local capture is its fallback.
|
||||
|
||||
### Remote SSH cases
|
||||
|
||||
|
||||
+20
-4
@@ -258,10 +258,26 @@ 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).
|
||||
visible frame and caret. Bounded (5 s for the round trip, at most 1.5 s queued
|
||||
for a slot in the shared ssh limiter) and fail-soft: any failure keeps the
|
||||
local capture, and a transport failure (ssh exit 255, a timeout, output that is
|
||||
not the script's) also skips that host for 30 s; a failure of the remote
|
||||
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:
|
||||
|
||||
- **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).
|
||||
- **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).
|
||||
|
||||
## File access over SSH
|
||||
|
||||
|
||||
+55
-27
@@ -17,11 +17,12 @@
|
||||
*
|
||||
* 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()`.
|
||||
* never worse than before. A transport 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 (`maxWaitMs`,
|
||||
* enforced in the queue), 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.
|
||||
@@ -30,8 +31,8 @@
|
||||
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 { buildSshConnectionArgs, classifyRemoteAliveExit, remoteSshTarget, shellescape } from './remote-hosts.js';
|
||||
import { RemoteSshSlotTimeoutError, runWithRemoteSshLimit } from './remote-ssh-limiter.js';
|
||||
import { remoteTmuxLocation } from './tmux-manager.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -129,48 +130,75 @@ export function parseRemoteScrollbackOutput(stdout: string): string | null {
|
||||
return stdout.slice(sizeEnd + 1);
|
||||
}
|
||||
|
||||
/** Runs the ssh command line and resolves its stdout; rejects like `exec` does. */
|
||||
export type RemoteScrollbackRunner = (command: string, opts: { timeout: number; maxBuffer: number }) => Promise<string>;
|
||||
|
||||
const execRunner: RemoteScrollbackRunner = async (command, opts) =>
|
||||
(await execAsync(command, { encoding: 'utf-8', ...opts })).stdout;
|
||||
|
||||
/**
|
||||
* Read a remote session's scrollback from the remote tmux, or null to keep the
|
||||
* local capture. Never throws.
|
||||
*
|
||||
* 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
|
||||
* null and the next session on that host still tries. Same split as
|
||||
* `classifyRemoteAliveExit()`.
|
||||
*
|
||||
* `run` is a test seam: without it nothing runs under vitest, as in
|
||||
* remote-files.ts / remote-hosts.ts.
|
||||
*/
|
||||
export async function fetchRemoteScrollback(
|
||||
remote: SessionRemote,
|
||||
sessionId: string,
|
||||
historyLines: number,
|
||||
maxBytes: number
|
||||
maxBytes: number,
|
||||
run?: RemoteScrollbackRunner
|
||||
): 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();
|
||||
if (!run && process.env.VITEST) return null;
|
||||
const runner = run ?? execRunner;
|
||||
const until = backoffUntil.get(remote.hostId);
|
||||
if (until !== undefined) {
|
||||
if (queuedAt < until) return null;
|
||||
if (Date.now() < 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 stdout = await runWithRemoteSshLimit(
|
||||
() =>
|
||||
runner(command, {
|
||||
timeout: REMOTE_HISTORY_TIMEOUT_MS,
|
||||
maxBuffer: (maxBytes > 0 ? maxBytes : 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) {
|
||||
backoffUntil.set(remote.hostId, Date.now() + REMOTE_HISTORY_BACKOFF_MS);
|
||||
console.warn(`[remote-history] ${remote.label}: unexpected output; using the local capture`);
|
||||
// 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;
|
||||
if (!ranOnHost) backoffUntil.set(remote.hostId, Date.now() + REMOTE_HISTORY_BACKOFF_MS);
|
||||
const what = ranOnHost ? 'no such remote tmux session' : 'unexpected output';
|
||||
console.warn(`[remote-history] ${remote.label}: ${what}; using the local capture`);
|
||||
return null;
|
||||
}
|
||||
return dropPartialFirstRow(scrollback, maxBytes);
|
||||
} catch (err) {
|
||||
backoffUntil.set(remote.hostId, Date.now() + REMOTE_HISTORY_BACKOFF_MS);
|
||||
// A busy pool says nothing about the host.
|
||||
if (err instanceof RemoteSshSlotTimeoutError) return null;
|
||||
const e = err as { killed?: boolean; code?: unknown };
|
||||
const reason = e.killed ? 'timed out' : typeof e.code === 'number' ? `exit ${e.code}` : 'failed';
|
||||
const code = typeof e.code === 'number' ? e.code : null;
|
||||
const transport = classifyRemoteAliveExit(code, e.killed === true) === undefined;
|
||||
if (transport) backoffUntil.set(remote.hostId, Date.now() + REMOTE_HISTORY_BACKOFF_MS);
|
||||
const reason = e.killed ? 'timed out' : code !== null ? `exit ${code}` : 'failed';
|
||||
console.warn(`[remote-history] ${remote.label}: remote scrollback read ${reason}; using the local capture`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @fileoverview Global concurrency limiter for the short-lived `ssh` children that
|
||||
* remote-case file access spawns (`src/remote-files.ts`: the realpath+stat probe and
|
||||
* the buffered text read).
|
||||
* the buffered text read). A remote session's full-history load reads its scrollback
|
||||
* from the remote tmux through the same pool (`src/remote-pane-history.ts`), with a
|
||||
* bounded wait (`maxWaitMs`) because it has the local capture to fall back on.
|
||||
*
|
||||
* Two paths can fan those out without a human behind each one:
|
||||
*
|
||||
@@ -60,12 +62,35 @@ export function getRemoteSshLimit(): number {
|
||||
return MAX_CONCURRENT_REMOTE_SSH;
|
||||
}
|
||||
|
||||
function acquire(): Promise<void> {
|
||||
/** Thrown by {@link runWithRemoteSshLimit} when `maxWaitMs` passed before a slot freed up. */
|
||||
export class RemoteSshSlotTimeoutError extends Error {
|
||||
constructor(maxWaitMs: number) {
|
||||
super(`No remote ssh slot within ${maxWaitMs} ms`);
|
||||
this.name = 'RemoteSshSlotTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
function acquire(maxWaitMs?: number): Promise<void> {
|
||||
if (active < MAX_CONCURRENT_REMOTE_SSH) {
|
||||
active++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => waiters.push(resolve));
|
||||
if (maxWaitMs === undefined) return new Promise<void>((resolve) => waiters.push(resolve));
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const waiter = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
// A timed-out waiter leaves the queue and is never handed a slot, so it
|
||||
// owns nothing to give back: `active` is untouched on this path.
|
||||
const timer = setTimeout(() => {
|
||||
const index = waiters.indexOf(waiter);
|
||||
if (index === -1) return; // already handed a slot by release()
|
||||
waiters.splice(index, 1);
|
||||
reject(new RemoteSshSlotTimeoutError(maxWaitMs));
|
||||
}, maxWaitMs);
|
||||
waiters.push(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
function release(): void {
|
||||
@@ -78,9 +103,14 @@ function release(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Run `task` once an ssh slot is free, releasing the slot afterward. */
|
||||
export async function runWithRemoteSshLimit<T>(task: () => Promise<T>): Promise<T> {
|
||||
await acquire();
|
||||
/**
|
||||
* Run `task` once an ssh slot is free, releasing the slot afterward. With
|
||||
* `maxWaitMs`, give up instead (rejecting with {@link RemoteSshSlotTimeoutError},
|
||||
* `task` never runs) when no slot frees up in time: for a caller that has a
|
||||
* cheaper answer than waiting behind the queue.
|
||||
*/
|
||||
export async function runWithRemoteSshLimit<T>(task: () => Promise<T>, opts: { maxWaitMs?: number } = {}): Promise<T> {
|
||||
await acquire(opts.maxWaitMs);
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* Port: N/A (no HTTP server).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
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';
|
||||
@@ -20,7 +20,14 @@ import {
|
||||
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';
|
||||
|
||||
@@ -150,7 +157,107 @@ describe('joinScrollbackAndFrame', () => {
|
||||
});
|
||||
|
||||
describe('fetchRemoteScrollback', () => {
|
||||
it('never opens a connection under test', async () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,18 @@
|
||||
/**
|
||||
* @fileoverview Tests for the remote-file ssh concurrency limiter
|
||||
* (`src/remote-ssh-limiter.ts`): the cap holds under interleaved async resumption,
|
||||
* waiters are served FIFO, and a task that throws still releases its slot.
|
||||
* waiters are served FIFO, a task that throws still releases its slot, and a
|
||||
* bounded (`maxWaitMs`) waiter that times out neither runs nor leaks a slot.
|
||||
*
|
||||
* Port: N/A (no HTTP server).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
getActiveRemoteSshCount,
|
||||
getQueuedRemoteSshCount,
|
||||
getRemoteSshLimit,
|
||||
RemoteSshSlotTimeoutError,
|
||||
runWithRemoteSshLimit,
|
||||
} from '../src/remote-ssh-limiter.js';
|
||||
|
||||
@@ -64,4 +66,75 @@ describe('runWithRemoteSshLimit', () => {
|
||||
expect(getActiveRemoteSshCount()).toBe(0);
|
||||
expect(await runWithRemoteSshLimit(async () => 'after')).toBe('after');
|
||||
});
|
||||
|
||||
describe('maxWaitMs', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('a waiter that times out leaves the queue, never runs, and takes no slot', async () => {
|
||||
vi.useFakeTimers();
|
||||
const cap = getRemoteSshLimit();
|
||||
const gates = Array.from({ length: cap }, () => deferred());
|
||||
const holders = gates.map((gate) => runWithRemoteSshLimit(() => gate.promise));
|
||||
await Promise.resolve();
|
||||
expect(getActiveRemoteSshCount()).toBe(cap);
|
||||
|
||||
let ran = false;
|
||||
const bounded = runWithRemoteSshLimit(
|
||||
async () => {
|
||||
ran = true;
|
||||
},
|
||||
{ maxWaitMs: 1500 }
|
||||
);
|
||||
const outcome = expect(bounded).rejects.toBeInstanceOf(RemoteSshSlotTimeoutError);
|
||||
// An unbounded waiter queued BEHIND it still gets the next slot.
|
||||
let laterRan = false;
|
||||
const later = runWithRemoteSshLimit(async () => {
|
||||
laterRan = true;
|
||||
});
|
||||
expect(getQueuedRemoteSshCount()).toBe(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
await outcome;
|
||||
expect(ran).toBe(false);
|
||||
expect(getQueuedRemoteSshCount()).toBe(1);
|
||||
expect(getActiveRemoteSshCount()).toBe(cap);
|
||||
|
||||
gates[0].resolve();
|
||||
await later;
|
||||
expect(laterRan).toBe(true);
|
||||
expect(ran).toBe(false);
|
||||
|
||||
for (const gate of gates) gate.resolve();
|
||||
await Promise.all(holders);
|
||||
expect(getActiveRemoteSshCount()).toBe(0);
|
||||
expect(getQueuedRemoteSshCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('a waiter handed a slot in time runs and releases normally', async () => {
|
||||
vi.useFakeTimers();
|
||||
const cap = getRemoteSshLimit();
|
||||
const gates = Array.from({ length: cap }, () => deferred());
|
||||
const holders = gates.map((gate) => runWithRemoteSshLimit(() => gate.promise));
|
||||
await Promise.resolve();
|
||||
|
||||
const bounded = runWithRemoteSshLimit(async () => 'ran', { maxWaitMs: 1500 });
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
gates[0].resolve();
|
||||
expect(await bounded).toBe('ran');
|
||||
// The cancelled timer firing later must not disturb anything.
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
for (const gate of gates) gate.resolve();
|
||||
await Promise.all(holders);
|
||||
expect(getActiveRemoteSshCount()).toBe(0);
|
||||
expect(getQueuedRemoteSshCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('takes a free slot at once without starting a timer', async () => {
|
||||
expect(await runWithRemoteSshLimit(async () => 'free', { maxWaitMs: 0 })).toBe('free');
|
||||
expect(getActiveRemoteSshCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,14 @@ describe('tmux full-history pane capture (COD-47)', () => {
|
||||
expect(source).toContain('capture-pane -p -e -J -S -${historyLines}');
|
||||
});
|
||||
|
||||
it('a full capture with a scrollbackOverride (remote history) reads the visible frame only (-J -S 0)', () => {
|
||||
// The remote tmux supplied the history; reading local history too would
|
||||
// splice two histories above one frame.
|
||||
expect(methodBody).toContain('scrollbackOverride !== undefined');
|
||||
expect(methodBody).toContain("'capture-pane -p -e -J -S 0'");
|
||||
expect(methodBody).toContain('joinScrollbackAndFrame(');
|
||||
});
|
||||
|
||||
it('full-history exec sets an explicit maxBuffer (default 1MB would ENOBUFS multi-MB dumps)', () => {
|
||||
expect(methodBody).toContain('maxBuffer');
|
||||
expect(methodBody).toContain('FULL_HISTORY_CAPTURE_SLACK_BYTES');
|
||||
|
||||
Reference in new issue
Block a user