mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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>
141 lines
4.8 KiB
TypeScript
141 lines
4.8 KiB
TypeScript
/**
|
|
* @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, 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 { afterEach, describe, it, expect, vi } from 'vitest';
|
|
import {
|
|
getActiveRemoteSshCount,
|
|
getQueuedRemoteSshCount,
|
|
getRemoteSshLimit,
|
|
RemoteSshSlotTimeoutError,
|
|
runWithRemoteSshLimit,
|
|
} from '../src/remote-ssh-limiter.js';
|
|
|
|
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>((r) => {
|
|
resolve = r;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
describe('runWithRemoteSshLimit', () => {
|
|
it('never lets more than the cap run at once, and queues the rest FIFO', async () => {
|
|
const cap = getRemoteSshLimit();
|
|
const gates = Array.from({ length: cap + 3 }, () => deferred());
|
|
const started: number[] = [];
|
|
let peak = 0;
|
|
|
|
const runs = gates.map((gate, index) =>
|
|
runWithRemoteSshLimit(async () => {
|
|
started.push(index);
|
|
peak = Math.max(peak, getActiveRemoteSshCount());
|
|
await gate.promise;
|
|
return index;
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
|
|
expect(started).toEqual(Array.from({ length: cap }, (_, i) => i));
|
|
expect(getActiveRemoteSshCount()).toBe(cap);
|
|
expect(getQueuedRemoteSshCount()).toBe(3);
|
|
|
|
// Releasing one hands the slot to the OLDEST waiter; the count stays at the cap.
|
|
gates[0].resolve();
|
|
await runs[0];
|
|
await Promise.resolve();
|
|
expect(started).toEqual([...Array.from({ length: cap }, (_, i) => i), cap]);
|
|
expect(getActiveRemoteSshCount()).toBe(cap);
|
|
|
|
for (const gate of gates) gate.resolve();
|
|
expect(await Promise.all(runs)).toEqual(gates.map((_, i) => i));
|
|
expect(peak).toBe(cap);
|
|
expect(getActiveRemoteSshCount()).toBe(0);
|
|
expect(getQueuedRemoteSshCount()).toBe(0);
|
|
});
|
|
|
|
it('releases the slot when the task throws', async () => {
|
|
await expect(runWithRemoteSshLimit(async () => Promise.reject(new Error('ssh exit 255')))).rejects.toThrow(
|
|
'ssh exit 255'
|
|
);
|
|
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);
|
|
});
|
|
});
|
|
});
|