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,5 @@
---
"@jmfederico/pi-web": patch
---
Fix multi-minute stalls when opening the model selector, starting sessions, or using the auth dialogs. Provider catalog refreshes no longer run unbounded network fetches on request paths: the shared model runtime now operates offline and pi-web refreshes provider catalogs itself on a bounded, background schedule and after provider logins.
+10 -2
View File
@@ -9,6 +9,7 @@ import { SessionEventHub } from "./realtime/sessionEventHub.js";
import { AuthService } from "./sessions/authService.js";
import { bootstrapAndFreezeGlobalExtensionProviders } from "./sessions/globalProviderPolicy.js";
import { registerAuthRoutes } from "./sessions/authRoutes.js";
import { ModelCatalogRefresher } from "./sessions/modelCatalogRefresher.js";
import { PiSessionService } from "./sessions/piSessionService.js";
import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js";
import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
@@ -55,6 +56,12 @@ await runSessionDaemonStartup({
// still mutable, then freeze every later extension-provider mutation before
// any real session can load project resources.
await bootstrapAndFreezeGlobalExtensionProviders(auth.runtime, activeAgentProfile.dir, app.log);
// The shared model runtime is constructed offline so request paths never
// wait on provider-catalog fetches; this is the single bounded network
// refresher, and auth changes (login/logout) ask it for a prompt run.
const catalogRefresher = new ModelCatalogRefresher({ runtime: auth.runtime, logger: app.log });
catalogRefresher.start();
auth.subscribe(() => { catalogRefresher.requestRefresh(); });
const spawnTargets = config.spawnSessions
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
: undefined;
@@ -79,7 +86,7 @@ await runSessionDaemonStartup({
...getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES),
activeAgentProfile,
});
return { eventHub, workspaceActivity, auth, sessions, terminals, unreadStore, activeAgentProfile, runtimeComponent };
return { eventHub, workspaceActivity, auth, sessions, terminals, unreadStore, activeAgentProfile, runtimeComponent, catalogRefresher };
},
registerRoutes({ eventHub, workspaceActivity, auth, sessions, terminals, runtimeComponent }) {
registerWorkspaceActivityRoutes(app, workspaceActivity);
@@ -102,7 +109,7 @@ await runSessionDaemonStartup({
app.get("/runtime", () => runtimeComponent);
},
async listen({ auth, sessions, terminals, unreadStore }) {
async listen({ auth, sessions, terminals, unreadStore, catalogRefresher }) {
let shuttingDown = false;
async function shutdown(signal: NodeJS.Signals): Promise<void> {
if (shuttingDown) return;
@@ -117,6 +124,7 @@ await runSessionDaemonStartup({
}
};
await attempt("dispose terminals", () => { terminals.dispose(); });
await attempt("dispose catalog refresher", () => { catalogRefresher.dispose(); });
await attempt("dispose auth", () => { auth.dispose(); });
await attempt("dispose sessions", () => sessions.dispose());
await attempt("flush session unread state", () => unreadStore.flush());
+35 -2
View File
@@ -362,7 +362,7 @@ describe("AuthService", () => {
it("stores credentials in the configured agent directory", async () => {
const agentDir = await tempAgentDir();
const runtime = await createModelRuntimeForAgentDir(agentDir, false);
const runtime = await createModelRuntimeForAgentDir(agentDir);
const auth = await AuthService.create({ runtime });
await auth.saveApiKey("anthropic", "sk-test");
@@ -470,6 +470,39 @@ describe("AuthService", () => {
});
});
describe("createModelRuntimeForAgentDir", () => {
it("disables runtime-owned network refreshes so request paths stay local", async () => {
// The stall fix relies on this construction-time flag: reloadConfig(),
// login(), and logout() refresh with allowNetwork = modelNetworkEnabled.
// Asserting the private field is proportionate here because the flag is
// exactly the contract this change depends on.
const agentDir = await tempAgentDir();
const runtime = await createModelRuntimeForAgentDir(agentDir);
expect(Reflect.get(runtime, "modelNetworkEnabled")).toBe(false);
});
it("restores a previously set PI_OFFLINE after runtime creation", async () => {
// The file-level beforeEach stubs PI_OFFLINE=1.
const agentDir = await tempAgentDir();
await createModelRuntimeForAgentDir(agentDir);
expect(process.env["PI_OFFLINE"]).toBe("1");
});
it("restores a previously unset PI_OFFLINE after runtime creation", async () => {
vi.unstubAllEnvs();
const previous = process.env["PI_OFFLINE"];
delete process.env["PI_OFFLINE"];
try {
const agentDir = await tempAgentDir();
const runtime = await createModelRuntimeForAgentDir(agentDir);
expect(Reflect.get(runtime, "modelNetworkEnabled")).toBe(false);
expect(process.env["PI_OFFLINE"]).toBeUndefined();
} finally {
if (previous !== undefined) process.env["PI_OFFLINE"] = previous;
}
});
});
async function createAuthService(seed: Record<string, Credential> = {}, logger?: AuthServiceLogger) {
const credentials = new InMemoryCredentialStore();
for (const [providerId, credential] of Object.entries(seed)) {
@@ -486,7 +519,7 @@ async function createFileBackedAuthService(seed: Record<string, Credential>) {
const agentDir = await tempAgentDir();
const authPath = join(agentDir, "auth.json");
await writeFile(authPath, JSON.stringify(seed, null, 2));
const runtime = await createModelRuntimeForAgentDir(agentDir, false);
const runtime = await createModelRuntimeForAgentDir(agentDir);
const auth = await AuthService.create({ runtime });
const changes: AuthChange[] = [];
auth.subscribe((change) => { changes.push(change); });
+27 -5
View File
@@ -1,5 +1,5 @@
import { join } from "node:path";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { ModelRuntime, type CreateModelRuntimeOptions } from "@earendil-works/pi-coding-agent";
import type { AuthInteraction } from "@earendil-works/pi-ai";
import type { AuthProvidersResponse, AuthType, OAuthFlowState } from "../../shared/apiTypes.js";
import { getLoginProviderOptions, getLogoutProviderOptions } from "./authProviderOptions.js";
@@ -31,11 +31,33 @@ interface AuthChangeContext {
const noopLogger: AuthServiceLogger = { error() { /* no-op */ } };
export function createModelRuntimeForAgentDir(agentDir: string, allowModelNetwork?: boolean): Promise<ModelRuntime> {
return ModelRuntime.create({
/**
* Create the shared model runtime with runtime-owned network refreshes disabled.
*
* Upstream `ModelRuntime.reloadConfig()`, `login()`, and `logout()` always refresh
* with `allowNetwork: modelNetworkEnabled` and accept no abort signal. With the
* default (`PI_OFFLINE` unset) a single stalled provider-catalog fetch can block
* those call paths for minutes — and, because pi-web shares one runtime and pi
* coalesces per-provider refreshes, session creation joins the same stalled
* fetch. Forcing `PI_OFFLINE` during construction makes every runtime-driven
* refresh local-only; pi-web performs its own bounded catalog refreshes in the
* background instead (see modelCatalogRefresher.ts).
*/
async function createOfflineModelRuntime(options: CreateModelRuntimeOptions): Promise<ModelRuntime> {
const previous = process.env["PI_OFFLINE"];
process.env["PI_OFFLINE"] = "1";
try {
return await ModelRuntime.create(options);
} finally {
if (previous === undefined) delete process.env["PI_OFFLINE"];
else process.env["PI_OFFLINE"] = previous;
}
}
export function createModelRuntimeForAgentDir(agentDir: string): Promise<ModelRuntime> {
return createOfflineModelRuntime({
authPath: join(agentDir, "auth.json"),
modelsPath: join(agentDir, "models.json"),
...(allowModelNetwork === undefined ? {} : { allowModelNetwork }),
});
}
@@ -52,7 +74,7 @@ export class AuthService {
}
static async create(deps: AuthServiceDependencies = {}): Promise<AuthService> {
const runtime = deps.runtime ?? (deps.agentDir === undefined ? await ModelRuntime.create({}) : await createModelRuntimeForAgentDir(deps.agentDir));
const runtime = deps.runtime ?? (deps.agentDir === undefined ? await createOfflineModelRuntime({}) : await createModelRuntimeForAgentDir(deps.agentDir));
const logger = deps.logger ?? noopLogger;
const authFlows = deps.authFlows ?? new OAuthLoginFlowService({ logger });
return new AuthService(runtime, authFlows, logger);
@@ -0,0 +1,173 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ModelCatalogRefresher } from "./modelCatalogRefresher.js";
interface RefreshCall {
allowNetwork?: boolean;
signal?: AbortSignal;
}
interface RefreshResult {
aborted: boolean;
errors: Map<string, Error>;
}
const okResult = (): RefreshResult => ({ aborted: false, errors: new Map<string, Error>() });
function deferred<T>() {
let resolveValue: (value: T) => void = () => undefined;
const promise = new Promise<T>((resolve) => {
resolveValue = resolve;
});
return { promise, resolve: resolveValue };
}
function createRuntime() {
const calls: RefreshCall[] = [];
const refresh = vi.fn((options?: RefreshCall) => {
calls.push(options ?? {});
return Promise.resolve(okResult());
});
return { refresh, calls };
}
function createLogger() {
const warn = vi.fn();
const error = vi.fn();
return { logger: { warn, error }, warn, error };
}
/** Let a started refresh run to completion, including the finally-queue bookkeeping. */
async function flushMicrotasks(rounds = 5): Promise<void> {
for (let index = 0; index < rounds; index++) await Promise.resolve();
}
describe("ModelCatalogRefresher", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("runs a bounded network refresh when one is requested", async () => {
const runtime = createRuntime();
const refresher = new ModelCatalogRefresher({ runtime });
refresher.requestRefresh();
await flushMicrotasks();
expect(runtime.refresh).toHaveBeenCalledOnce();
const call = runtime.calls.at(0);
expect(call?.allowNetwork).toBe(true);
expect(call?.signal).toBeInstanceOf(AbortSignal);
refresher.dispose();
});
it("coalesces overlapping requests into a single follow-up run", async () => {
const gate = deferred<RefreshResult>();
const refresh = vi.fn()
.mockImplementationOnce(() => gate.promise)
.mockResolvedValue(okResult());
const refresher = new ModelCatalogRefresher({ runtime: { refresh } });
refresher.requestRefresh();
refresher.requestRefresh();
refresher.requestRefresh();
expect(refresh).toHaveBeenCalledOnce();
gate.resolve(okResult());
await flushMicrotasks();
expect(refresh).toHaveBeenCalledTimes(2);
refresher.dispose();
});
it("refreshes after the initial delay and then on the interval", async () => {
const runtime = createRuntime();
const refresher = new ModelCatalogRefresher({ runtime, initialDelayMs: 1_000, intervalMs: 60_000 });
refresher.start();
await vi.advanceTimersByTimeAsync(999);
expect(runtime.refresh).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(runtime.refresh).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(60_000);
expect(runtime.refresh).toHaveBeenCalledTimes(2);
refresher.dispose();
});
it("stops scheduling refreshes after dispose", async () => {
const runtime = createRuntime();
const refresher = new ModelCatalogRefresher({ runtime, initialDelayMs: 1_000, intervalMs: 60_000 });
refresher.start();
refresher.dispose();
await vi.advanceTimersByTimeAsync(120_000);
expect(runtime.refresh).not.toHaveBeenCalled();
});
it("does not run a queued follow-up after dispose", async () => {
const gate = deferred<RefreshResult>();
const refresh = vi.fn().mockImplementation(() => gate.promise);
const refresher = new ModelCatalogRefresher({ runtime: { refresh } });
refresher.requestRefresh();
refresher.requestRefresh();
refresher.dispose();
gate.resolve(okResult());
await flushMicrotasks();
expect(refresh).toHaveBeenCalledOnce();
});
it("warns and keeps going when a refresh is aborted by its timeout", async () => {
const { logger, warn, error } = createLogger();
const refresh = vi.fn(() => Promise.resolve({ aborted: true, errors: new Map<string, Error>() }));
const refresher = new ModelCatalogRefresher({ runtime: { refresh }, logger });
refresher.requestRefresh();
await flushMicrotasks();
expect(warn).toHaveBeenCalledOnce();
expect(error).not.toHaveBeenCalled();
refresher.dispose();
});
it("warns with provider details when a refresh reports provider errors", async () => {
const { logger, warn } = createLogger();
const errors = new Map<string, Error>([["openrouter", new Error("boom")]]);
const refresh = vi.fn(() => Promise.resolve({ aborted: false, errors }));
const refresher = new ModelCatalogRefresher({ runtime: { refresh }, logger });
refresher.requestRefresh();
await flushMicrotasks();
expect(warn).toHaveBeenCalledWith(
{ providers: ["openrouter: boom"] },
"model catalog refresh failed for some providers; keeping cached catalogs",
);
refresher.dispose();
});
it("logs and swallows a rejecting refresh so timers stay alive", async () => {
const { logger, error } = createLogger();
const failure = new Error("refresh exploded");
const refresh = vi.fn()
.mockRejectedValueOnce(failure)
.mockResolvedValue(okResult());
const refresher = new ModelCatalogRefresher({ runtime: { refresh }, logger, initialDelayMs: 1_000, intervalMs: 60_000 });
refresher.requestRefresh();
await flushMicrotasks();
expect(error).toHaveBeenCalledWith({ err: failure }, "model catalog refresh failed; keeping cached catalogs");
refresher.start();
await vi.advanceTimersByTimeAsync(61_000);
expect(refresh).toHaveBeenCalledTimes(3);
refresher.dispose();
});
});
@@ -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");
}
}
}