76ca917bfd
- Added timeout pause/resume control ops and helper to suspend idle timers during bridge work. - Fixed TimeoutError on long delegated agent()/llm() calls by pausing timeout while silent. - Updated IdleTimeout with reference-counted pauses, ignored checks while paused, and resumed fresh. - Replaced heartbeat keepalives with timeout-control events in bridge paths and status routing.
99 lines
3.0 KiB
TypeScript
99 lines
3.0 KiB
TypeScript
/**
|
|
* Watchdog for eval cell work.
|
|
*
|
|
* A cell's `timeout` bounds time while the Python kernel or JS VM is in control.
|
|
* Host-side bridge calls can {@link pause} the watchdog so delegated
|
|
* `agent()`/`parallel()`/`llm()` work is ignored completely, then {@link resume}
|
|
* starts a fresh timeout window once the runtime gets control back.
|
|
*
|
|
* The active timer self-reschedules instead of being torn down on every
|
|
* activity event, so frequent activity costs one timestamp write per event.
|
|
* Pause is reference-counted because `parallel()` can have multiple bridge calls
|
|
* in flight at once.
|
|
*/
|
|
export class IdleTimeout {
|
|
readonly #controller = new AbortController();
|
|
readonly #idleMs: number;
|
|
/** Absolute time (epoch ms) at which inactivity is considered to have expired. */
|
|
#deadlineMs: number;
|
|
#timer: NodeJS.Timeout | undefined;
|
|
#settled = false;
|
|
#pauseDepth = 0;
|
|
|
|
constructor(idleMs: number) {
|
|
this.#idleMs = Math.max(1, Math.floor(idleMs));
|
|
this.#deadlineMs = Date.now() + this.#idleMs;
|
|
this.#arm(this.#idleMs);
|
|
}
|
|
|
|
/** Aborts with a `TimeoutError` reason once the active timeout window is exhausted. */
|
|
get signal(): AbortSignal {
|
|
return this.#controller.signal;
|
|
}
|
|
|
|
/** Configured active timeout window in milliseconds. */
|
|
get idleMs(): number {
|
|
return this.#idleMs;
|
|
}
|
|
|
|
/** Record runtime activity, pushing the active deadline forward by `idleMs`. */
|
|
bump(): void {
|
|
if (this.#settled || this.#pauseDepth > 0) return;
|
|
this.#deadlineMs = Date.now() + this.#idleMs;
|
|
}
|
|
/** Suspend timeout accounting while control is delegated to host-side work. */
|
|
pause(): void {
|
|
if (this.#settled) return;
|
|
this.#pauseDepth++;
|
|
if (this.#pauseDepth !== 1) return;
|
|
if (this.#timer) {
|
|
clearTimeout(this.#timer);
|
|
this.#timer = undefined;
|
|
}
|
|
}
|
|
|
|
/** Resume timeout accounting with a fresh timeout window. */
|
|
resume(): void {
|
|
if (this.#settled || this.#pauseDepth === 0) return;
|
|
this.#pauseDepth--;
|
|
if (this.#pauseDepth > 0) return;
|
|
this.#deadlineMs = Date.now() + this.#idleMs;
|
|
this.#arm(this.#idleMs);
|
|
}
|
|
|
|
/** Stop the watchdog. Safe to call multiple times. */
|
|
dispose(): void {
|
|
if (this.#settled) return;
|
|
this.#settled = true;
|
|
if (this.#timer) {
|
|
clearTimeout(this.#timer);
|
|
this.#timer = undefined;
|
|
}
|
|
}
|
|
|
|
[Symbol.dispose](): void {
|
|
this.dispose();
|
|
}
|
|
|
|
#arm(delayMs: number): void {
|
|
const timer = setTimeout(() => this.#onExpire(), Math.max(0, delayMs));
|
|
// Never keep the event loop alive for the watchdog itself.
|
|
timer.unref?.();
|
|
this.#timer = timer;
|
|
}
|
|
|
|
#onExpire(): void {
|
|
if (this.#settled || this.#pauseDepth > 0) return;
|
|
const remainingMs = this.#deadlineMs - Date.now();
|
|
if (remainingMs > 0) {
|
|
// A bump moved the deadline forward after this timer was armed; wait
|
|
// out the remaining window instead of firing early.
|
|
this.#arm(remainingMs);
|
|
return;
|
|
}
|
|
this.#settled = true;
|
|
this.#timer = undefined;
|
|
this.#controller.abort(new DOMException(`Idle for ${Math.round(this.#idleMs / 1000)}s`, "TimeoutError"));
|
|
}
|
|
}
|