blob: 58dc50a15a8c69b091234b05030fc91ad95212e8 [file]
export const DEFAULT_STREAM_CONNECT_TIMEOUT_MS = 30_000;
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120_000;
export type StreamWatchdogPhase = 'connect' | 'idle';
export interface StreamWatchdogTimeout {
phase: StreamWatchdogPhase;
elapsedMs: number;
}
export interface StreamWatchdogInput {
now: () => number;
onTimeout: (timeout: StreamWatchdogTimeout) => void;
connectTimeoutMs?: number;
idleTimeoutMs?: number;
setTimer?: (callback: () => void, delayMs: number) => unknown;
clearTimer?: (timer: unknown) => void;
}
/**
* Small watchdog for LLM streams.
*
* It has two phases:
* - connect: no model/tool event has arrived yet.
* - idle: at least one event arrived, then the stream went quiet.
*
* Permission waits are paused by the backend, because user approval is not
* model silence.
*/
export class StreamWatchdog {
private readonly now: () => number;
private readonly onTimeout: (timeout: StreamWatchdogTimeout) => void;
private readonly connectTimeoutMs: number;
private readonly idleTimeoutMs: number;
private readonly setTimer: (callback: () => void, delayMs: number) => unknown;
private readonly clearTimer: (timer: unknown) => void;
private startedAt = 0;
private lastActivityAt = 0;
private sawActivity = false;
private pauseCount = 0;
private stopped = false;
private timer: unknown;
constructor(input: StreamWatchdogInput) {
this.now = input.now;
this.onTimeout = input.onTimeout;
this.connectTimeoutMs = input.connectTimeoutMs ?? DEFAULT_STREAM_CONNECT_TIMEOUT_MS;
this.idleTimeoutMs = input.idleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
this.setTimer = input.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
this.clearTimer =
input.clearTimer ?? ((timer) => clearTimeout(timer as ReturnType<typeof setTimeout>));
}
start(): void {
if (this.stopped) return;
const now = this.now();
this.startedAt = now;
this.lastActivityAt = now;
this.sawActivity = false;
this.pauseCount = 0;
this.schedule(this.connectTimeoutMs);
}
markActivity(): void {
if (this.stopped) return;
this.sawActivity = true;
this.lastActivityAt = this.now();
if (this.pauseCount === 0) this.schedule(this.idleTimeoutMs);
}
pause(): void {
if (this.stopped) return;
this.pauseCount += 1;
this.clear();
}
resume(): void {
if (this.stopped) return;
this.pauseCount = Math.max(0, this.pauseCount - 1);
if (this.pauseCount > 0) return;
this.markActivity();
}
stop(): void {
this.stopped = true;
this.clear();
}
private schedule(delayMs: number): void {
this.clear();
if (delayMs <= 0) return;
this.timer = this.setTimer(() => this.fire(), delayMs);
}
private clear(): void {
if (this.timer !== undefined) {
this.clearTimer(this.timer);
this.timer = undefined;
}
}
private fire(): void {
if (this.stopped || this.pauseCount > 0) return;
this.stopped = true;
this.clear();
const phase: StreamWatchdogPhase = this.sawActivity ? 'idle' : 'connect';
const anchor = this.sawActivity ? this.lastActivityAt : this.startedAt;
this.onTimeout({
phase,
elapsedMs: Math.max(0, this.now() - anchor),
});
}
}
export function formatStreamWatchdogError(timeout: StreamWatchdogTimeout): string {
if (timeout.phase === 'connect') {
return `Model stream connect timeout after ${timeout.elapsedMs}ms`;
}
return `Model stream idle timeout after ${timeout.elapsedMs}ms`;
}