mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01: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>
120 lines
4.7 KiB
TypeScript
120 lines
4.7 KiB
TypeScript
/**
|
|
* @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). 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:
|
|
*
|
|
* - `GET /api/sessions/:id/attachments` resolves every history entry (up to
|
|
* `ATTACHMENT_HISTORY_LIMIT`, 100), and the attachments drawer re-runs it on every
|
|
* `attachment:detected` event while it is open, which is exactly when an agent is
|
|
* writing files. The route now batches the probes, but a burst of drawers is still
|
|
* a burst.
|
|
* - A `codeman://attach?path=` magic link in terminal output registers the path
|
|
* fire-and-forget, once per distinct link per PTY chunk. In a remote session that
|
|
* output is written by a process on the remote host, so a prompt-injected agent can
|
|
* print hundreds of links and have the server fork one `ssh` per link, each holding
|
|
* a 20s probe timeout.
|
|
*
|
|
* Without a cap that is the fork-bomb shape `document-conversion-limiter.ts` exists to
|
|
* prevent, and it also trips OpenSSH's default `MaxStartups 10:30:100`, which starts
|
|
* dropping connections at ten unauthenticated handshakes. This is that limiter for
|
|
* ssh: a small fixed pool, FIFO queueing, and a slot handed straight to the next
|
|
* waiter on release so the active count can never exceed the cap under interleaved
|
|
* async resumption.
|
|
*
|
|
* Streams (`remoteCreateReadStream`) are deliberately NOT counted: one is opened per
|
|
* browser request and held for the life of a media playback, so four open videos
|
|
* would otherwise block every preview and the history list. They are already gated
|
|
* behind a counted probe (the guard re-probe runs first), so their spawn RATE is
|
|
* bounded here even though their concurrency is bounded by the browser.
|
|
*
|
|
* NOT re-entrant: never acquire from inside a task already holding a slot.
|
|
*/
|
|
|
|
/**
|
|
* Max remote probes/reads allowed to run concurrently across the whole process.
|
|
* Override with CODEMAN_MAX_REMOTE_FILE_SSH (clamped to >= 1). Four keeps a burst
|
|
* well under OpenSSH's ten-handshake default.
|
|
*/
|
|
const MAX_CONCURRENT_REMOTE_SSH = (() => {
|
|
const raw = Number(process.env.CODEMAN_MAX_REMOTE_FILE_SSH);
|
|
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 4;
|
|
})();
|
|
|
|
let active = 0;
|
|
const waiters: Array<() => void> = [];
|
|
|
|
/** Test/diagnostic hook: remote calls currently holding a slot. */
|
|
export function getActiveRemoteSshCount(): number {
|
|
return active;
|
|
}
|
|
|
|
/** Test/diagnostic hook: remote calls queued behind the cap. */
|
|
export function getQueuedRemoteSshCount(): number {
|
|
return waiters.length;
|
|
}
|
|
|
|
/** The configured cap, so a test can assert against the real number. */
|
|
export function getRemoteSshLimit(): number {
|
|
return MAX_CONCURRENT_REMOTE_SSH;
|
|
}
|
|
|
|
/** 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();
|
|
}
|
|
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 {
|
|
const next = waiters.shift();
|
|
if (next) {
|
|
// Hand the slot straight to the next waiter; `active` stays at the cap.
|
|
next();
|
|
} else {
|
|
active--;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
release();
|
|
}
|
|
}
|