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:
Federico Jaramillo Martinez
2026-07-24 23:44:35 +02:00
parent 83e9014a8a
commit ed9c2f65bb
6 changed files with 358 additions and 9 deletions
@@ -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");
}
}
}