Archived
fix(sessions): move provider catalog network refreshes off request paths
The shared ModelRuntime was constructed with network refreshes enabled, so reloadConfig()/login()/logout() — called on the model picker, session model changes, and auth dialogs — performed unbounded provider-catalog fetches. A single stalled fetch blocked those requests for minutes and, through pi's coalesced per-provider refresh, dragged session creation along with it. Construct the runtime with PI_OFFLINE forced so every runtime-driven refresh stays local, and add ModelCatalogRefresher as the single deliberate network path: bounded by an abort timeout, serialized through one in-flight run, scheduled in the background, and triggered after provider auth changes.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
/**
|
||||
* Matches pi's REMOTE_CATALOG_REFRESH_INTERVAL_MS: provider catalog entries in
|
||||
* models-store.json are treated as fresh for four hours.
|
||||
*/
|
||||
const DEFAULT_INTERVAL_MS = 4 * 60 * 60 * 1000;
|
||||
/** Give the daemon a moment to finish startup before the first network refresh. */
|
||||
const DEFAULT_INITIAL_DELAY_MS = 15_000;
|
||||
/** Bound every catalog refresh so a stalled provider fetch can never block for minutes. */
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Minimal structured-logging seam for non-fatal refresh failures. */
|
||||
export interface ModelCatalogRefresherLogger {
|
||||
warn(details: Record<string, unknown>, message: string): void;
|
||||
error(details: Record<string, unknown>, message: string): void;
|
||||
}
|
||||
|
||||
export interface ModelCatalogRefresherOptions {
|
||||
runtime: Pick<ModelRuntime, "refresh">;
|
||||
logger?: ModelCatalogRefresherLogger;
|
||||
intervalMs?: number;
|
||||
initialDelayMs?: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
const noopLogger: ModelCatalogRefresherLogger = {
|
||||
warn() { /* no-op */ },
|
||||
error() { /* no-op */ },
|
||||
};
|
||||
|
||||
/**
|
||||
* Refreshes provider model catalogs over the network on a background schedule.
|
||||
*
|
||||
* The shared ModelRuntime is constructed offline (see authService.ts), so its
|
||||
* own refreshes never touch the network and stay fast on request paths. This
|
||||
* refresher is the single place that deliberately performs network refreshes —
|
||||
* bounded by an abort timeout, serialized through one in-flight run, and off
|
||||
* any request path. `requestRefresh()` additionally asks for a prompt refresh
|
||||
* after events that change what should be listed, such as provider logins.
|
||||
*/
|
||||
export class ModelCatalogRefresher {
|
||||
private readonly runtime: Pick<ModelRuntime, "refresh">;
|
||||
private readonly logger: ModelCatalogRefresherLogger;
|
||||
private readonly intervalMs: number;
|
||||
private readonly initialDelayMs: number;
|
||||
private readonly timeoutMs: number;
|
||||
private initialTimer?: NodeJS.Timeout;
|
||||
private intervalTimer?: NodeJS.Timeout;
|
||||
private inflight: Promise<void> | undefined;
|
||||
private queued = false;
|
||||
private disposed = false;
|
||||
|
||||
constructor(options: ModelCatalogRefresherOptions) {
|
||||
this.runtime = options.runtime;
|
||||
this.logger = options.logger ?? noopLogger;
|
||||
this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
||||
this.initialDelayMs = options.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;
|
||||
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.disposed) return;
|
||||
this.initialTimer = setTimeout(() => { this.requestRefresh(); }, this.initialDelayMs);
|
||||
this.initialTimer.unref();
|
||||
this.intervalTimer = setInterval(() => { this.requestRefresh(); }, this.intervalMs);
|
||||
this.intervalTimer.unref();
|
||||
}
|
||||
|
||||
/** Ask for a refresh, coalescing concurrent and overlapping requests. */
|
||||
requestRefresh(): void {
|
||||
if (this.disposed) return;
|
||||
if (this.inflight !== undefined) {
|
||||
this.queued = true;
|
||||
return;
|
||||
}
|
||||
const run = this.run();
|
||||
this.inflight = run;
|
||||
this.inflight.finally(() => {
|
||||
this.inflight = undefined;
|
||||
if (this.queued && !this.disposed) {
|
||||
this.queued = false;
|
||||
this.requestRefresh();
|
||||
}
|
||||
}).catch(() => { /* run() never rejects; finally() re-throws otherwise */ });
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
if (this.initialTimer !== undefined) clearTimeout(this.initialTimer);
|
||||
if (this.intervalTimer !== undefined) clearInterval(this.intervalTimer);
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
try {
|
||||
const result = await this.runtime.refresh({ allowNetwork: true, signal: AbortSignal.timeout(this.timeoutMs) });
|
||||
if (result.aborted) {
|
||||
this.logger.warn({ timeoutMs: this.timeoutMs }, "model catalog refresh timed out; keeping cached catalogs");
|
||||
}
|
||||
if (result.errors.size > 0) {
|
||||
const providers = [...result.errors.entries()].map(([providerId, error]) => `${providerId}: ${error.message}`);
|
||||
this.logger.warn({ providers }, "model catalog refresh failed for some providers; keeping cached catalogs");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({ err: error }, "model catalog refresh failed; keeping cached catalogs");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user