From dfddda7969016acbe603e41a8a15fccd7158d2bb Mon Sep 17 00:00:00 2001 From: Fabian Fischer Date: Fri, 5 Jun 2026 14:19:05 +0200 Subject: [PATCH] feat(server): make provider refresh timeout configurable via PASEO_PROVIDER_REFRESH_TIMEOUT_MS (#1346) * feat(server): make provider refresh timeout configurable Provider refresh probes (isAvailable + listModels/listModes) were hardcoded to a 30s timeout. With many providers and MCP servers configured, cold starts of agents like Copilot can exceed this on the first probe, surfacing 'Timed out refreshing Copilot after 30000ms' even though a manual retry succeeds (warm caches). Add a PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var so operators can bump the ceiling without rebuilding. The explicit constructor option still wins over the env var, and invalid values fall back to the 30s default. * refactor(server): address review feedback on refresh timeout config - Use Number() instead of Number.parseInt() so scientific notation like '6e4' parses as 60000 instead of being silently truncated to 6. - Use vi.stubEnv()/vi.unstubAllEnvs() in tests to match the rest of the suite and avoid manual process.env save/restore. --- .../agent/provider-snapshot-manager.test.ts | 84 +++++++++++++++++++ .../server/agent/provider-snapshot-manager.ts | 21 ++++- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index b28accd07..6939089b6 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -156,6 +156,90 @@ describe("ProviderSnapshotManager public surface", () => { } }); + test("refreshTimeoutMs option overrides the default and yields a timeout error", async () => { + // never-resolving isAvailable forces the timeout path + const isAvailable = vi.fn(() => new Promise(() => {})); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + refreshTimeoutMs: 1, + providerOverrides: { + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { codex: createExtraClient("codex", { isAvailable }) }, + }); + try { + const entry = await manager.getProvider({ + cwd: "/tmp/project", + provider: "codex", + wait: true, + }); + expect(entry.provider).toBe("codex"); + expect(entry.status).toBe("error"); + expect(entry.error).toMatch(/after 1ms/); + } finally { + manager.destroy(); + } + }); + + test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is honored when no option is given", async () => { + vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1"); + const isAvailable = vi.fn(() => new Promise(() => {})); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { codex: createExtraClient("codex", { isAvailable }) }, + }); + try { + const entry = await manager.getProvider({ + cwd: "/tmp/project", + provider: "codex", + wait: true, + }); + expect(entry.status).toBe("error"); + expect(entry.error).toMatch(/after 1ms/); + } finally { + manager.destroy(); + vi.unstubAllEnvs(); + } + }); + + test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is ignored when option is provided", async () => { + vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1"); + const isAvailable = vi.fn(() => new Promise(() => {})); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + refreshTimeoutMs: 5, + providerOverrides: { + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { codex: createExtraClient("codex", { isAvailable }) }, + }); + try { + const entry = await manager.getProvider({ + cwd: "/tmp/project", + provider: "codex", + wait: true, + }); + expect(entry.status).toBe("error"); + // explicit option (5) wins over env var (1) + expect(entry.error).toMatch(/after 5ms/); + } finally { + manager.destroy(); + vi.unstubAllEnvs(); + } + }); + test("listProviders returns an entry per registered provider", async () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger(), diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 08a51c2c5..0acbc3fe8 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -29,6 +29,25 @@ import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js import type { MutableDaemonConfig } from "../daemon-config-store.js"; const DEFAULT_REFRESH_TIMEOUT_MS = 30_000; +const REFRESH_TIMEOUT_ENV_VAR = "PASEO_PROVIDER_REFRESH_TIMEOUT_MS"; + +// Provider refresh probes can be slow on cold starts (e.g. Copilot's first +// `copilot --acp` invocation, OpenCode workspace probes with many MCP servers). +// Allow operators to bump the ceiling via env var without rebuilding. +function resolveRefreshTimeoutMs(option: number | undefined): number { + if (typeof option === "number" && Number.isFinite(option) && option > 0) { + return option; + } + const fromEnv = process.env[REFRESH_TIMEOUT_ENV_VAR]; + if (fromEnv) { + // Number() handles scientific notation (e.g. "6e4") which parseInt would silently truncate. + const parsed = Number(fromEnv); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return DEFAULT_REFRESH_TIMEOUT_MS; +} type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void; @@ -124,7 +143,7 @@ export class ProviderSnapshotManager { this.runtimeSettings = options.runtimeSettings; this.providerOverrides = options.providerOverrides; this.baseProviderOverrides = options.providerOverrides; - this.refreshTimeoutMs = options.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS; + this.refreshTimeoutMs = resolveRefreshTimeoutMs(options.refreshTimeoutMs); this.providerRegistry = this.buildRegistry(); this.providerClients = { ...this.extraClients } as Record; }