From 396090fc0319ab6a254a54dc97e3bf59c0fc68f5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 21 Jul 2026 17:14:17 -0700 Subject: [PATCH] fix(startup): bound model registry refresh so dashboard cannot hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-extension modelRegistry.refresh() had no timeout, so a hung remote catalog fetch left the TUI on "Loading extensions…" forever. Use a shared 15s-bounded refresh across dashboard/serve/daemon and related registration paths. --- packages/cli/src/commands/daemon.ts | 13 ++- packages/cli/src/commands/dashboard.ts | 14 ++- packages/cli/src/commands/serve.ts | 13 ++- .../__tests__/model-registry-refresh.test.ts | 54 ++++++++++++ .../engine/src/custom-provider-registry.ts | 15 +++- packages/engine/src/index.ts | 7 ++ packages/engine/src/model-registry-refresh.ts | 85 +++++++++++++++++++ packages/engine/src/pi.ts | 17 +++- packages/engine/src/provider-registration.ts | 10 ++- 9 files changed, 214 insertions(+), 14 deletions(-) create mode 100644 packages/engine/src/__tests__/model-registry-refresh.test.ts create mode 100644 packages/engine/src/model-registry-refresh.ts diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 59338bd4cf..3db97a2e48 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -37,6 +37,7 @@ import { setHostExtensionPaths, createFusionAuthStorage, createFusionModelRegistry, + refreshFusionModelRegistry, } from "@fusion/engine"; import { setHostTaskStore, clearHostTaskStores } from "../extension.js"; import { @@ -705,7 +706,13 @@ export async function runDaemon(opts: DaemonOptions = {}) { extensionsResult.runtime.pendingProviderRegistrations = []; mergeBuiltInZaiProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`)); mergeBuiltInGrokProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`)); - await modelRegistry.refresh(); + /* + FNXC:ModelRegistry 2026-07-21-17:15: + Bound post-extension refresh so a hung remote catalog cannot leave daemon stuck before listen. + */ + await refreshFusionModelRegistry(modelRegistry, { + log: (message) => console.log(`[extensions] ${message}`), + }); try { const globalSettings = await store.getGlobalSettingsStore().getSettings(); @@ -722,7 +729,9 @@ export async function runDaemon(opts: DaemonOptions = {}) { const message = error instanceof Error ? error.message : String(error); console.log(`[extensions] Failed to discover extensions: ${message}`); createExtensionRuntime(); - await modelRegistry.refresh(); + await refreshFusionModelRegistry(modelRegistry, { + log: (message) => console.log(`[extensions] ${message}`), + }); } void syncStartupModels({ diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index c0f3698407..78d2371994 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -68,6 +68,7 @@ import { setHostExtensionPaths, createFusionAuthStorage, createFusionModelRegistry, + refreshFusionModelRegistry, } from "@fusion/engine"; import { setHostTaskStore, clearHostTaskStores } from "../extension.js"; import { DefaultPackageManager, SettingsManager, discoverAndLoadExtensions, createExtensionRuntime } from "@earendil-works/pi-coding-agent"; @@ -1790,7 +1791,14 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: extensionsResult.runtime.pendingProviderRegistrations = []; mergeBuiltInZaiProviderModels(modelRegistry, (message) => logSink.log(message, "extensions")); mergeBuiltInGrokProviderModels(modelRegistry, (message) => logSink.log(message, "extensions")); - await modelRegistry.refresh(); + /* + FNXC:ModelRegistry 2026-07-21-17:15: + Unbounded modelRegistry.refresh() left the TUI on "Loading extensions…" forever when a remote + catalog fetch hung. Bound the post-extension refresh so dashboard startup always continues. + */ + await refreshFusionModelRegistry(modelRegistry, { + log: (message) => logSink.log(message, "extensions"), + }); try { const globalSettings = await store.getGlobalSettingsStore().getSettings(); @@ -1808,7 +1816,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: const message = error instanceof Error ? error.message : String(error); logSink.log(`Failed to discover extensions: ${message}`, "extensions"); createExtensionRuntime(); - await modelRegistry.refresh(); + await refreshFusionModelRegistry(modelRegistry, { + log: (message) => logSink.log(message, "extensions"), + }); } void syncStartupModels({ diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 06d6d9000d..caf6b45d1e 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -37,6 +37,7 @@ import { setHostExtensionPaths, createFusionAuthStorage, createFusionModelRegistry, + refreshFusionModelRegistry, } from "@fusion/engine"; import { setHostTaskStore, clearHostTaskStores } from "../extension.js"; import { @@ -813,7 +814,13 @@ export async function runServe( extensionsResult.runtime.pendingProviderRegistrations = []; mergeBuiltInZaiProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`)); mergeBuiltInGrokProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`)); - await modelRegistry.refresh(); + /* + FNXC:ModelRegistry 2026-07-21-17:15: + Bound post-extension refresh so a hung remote catalog cannot leave serve stuck before listen. + */ + await refreshFusionModelRegistry(modelRegistry, { + log: (message) => console.log(`[extensions] ${message}`), + }); try { const globalSettings = await store.getGlobalSettingsStore().getSettings(); @@ -831,7 +838,9 @@ export async function runServe( const message = error instanceof Error ? error.message : String(error); console.log(`[extensions] Failed to discover extensions: ${message}`); createExtensionRuntime(); - await modelRegistry.refresh(); + await refreshFusionModelRegistry(modelRegistry, { + log: (message) => console.log(`[extensions] ${message}`), + }); } void syncStartupModels({ diff --git a/packages/engine/src/__tests__/model-registry-refresh.test.ts b/packages/engine/src/__tests__/model-registry-refresh.test.ts new file mode 100644 index 0000000000..d1a59518d3 --- /dev/null +++ b/packages/engine/src/__tests__/model-registry-refresh.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + DEFAULT_MODEL_REGISTRY_REFRESH_TIMEOUT_MS, + refreshFusionModelRegistry, +} from "../model-registry-refresh.js"; + +describe("refreshFusionModelRegistry", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns completed when runtime refresh resolves", async () => { + const refresh = vi.fn(async () => ({ aborted: false })); + const outcome = await refreshFusionModelRegistry( + { refresh: vi.fn(), modelRuntime: { refresh } }, + { allowNetwork: false }, + ); + expect(outcome).toBe("completed"); + expect(refresh).toHaveBeenCalledWith( + expect.objectContaining({ allowNetwork: false, signal: expect.any(AbortSignal) }), + ); + }); + + it("times out a hung refresh instead of hanging forever", async () => { + vi.useFakeTimers(); + const refresh = vi.fn( + () => + new Promise(() => { + /* never settles */ + }), + ); + const log = vi.fn(); + const pending = refreshFusionModelRegistry( + { refresh: vi.fn(), modelRuntime: { refresh } }, + { timeoutMs: 50, log }, + ); + await vi.advanceTimersByTimeAsync(50); + await expect(pending).resolves.toBe("timed_out"); + expect(log).toHaveBeenCalledWith( + expect.stringContaining("timed out after 50ms"), + ); + }); + + it("falls back to ModelRegistry.refresh when modelRuntime is absent", async () => { + const refresh = vi.fn(async () => undefined); + const outcome = await refreshFusionModelRegistry({ refresh }); + expect(outcome).toBe("completed"); + expect(refresh).toHaveBeenCalledOnce(); + }); + + it("defaults timeout to the create-path bound", () => { + expect(DEFAULT_MODEL_REGISTRY_REFRESH_TIMEOUT_MS).toBe(15_000); + }); +}); diff --git a/packages/engine/src/custom-provider-registry.ts b/packages/engine/src/custom-provider-registry.ts index f411434049..6e5ef35fc1 100644 --- a/packages/engine/src/custom-provider-registry.ts +++ b/packages/engine/src/custom-provider-registry.ts @@ -6,8 +6,9 @@ custom-provider registration implementation. packages/cli/src/commands/custom-pr is now a thin re-export shim of this module; its observable behavior is unchanged. */ import { customProviderRegistryKey, type CustomProvider } from "@fusion/core"; +import { refreshFusionModelRegistry, type RefreshableModelRegistry } from "./model-registry-refresh.js"; -interface ModelRegistryLike { +interface ModelRegistryLike extends RefreshableModelRegistry { registerProvider: (name: string, config: { baseUrl: string; api: string; @@ -26,7 +27,7 @@ interface ModelRegistryLike { }; }>; }) => void; - refresh: () => Promise; + refresh: () => unknown; } /* @@ -138,7 +139,13 @@ export async function registerCustomProviders( } } - await modelRegistry.refresh(); + /* + FNXC:ModelRegistry 2026-07-21-17:15: + Unbounded modelRegistry.refresh() hung dashboard startup on "Loading extensions…" when a + provider catalog fetch never completed. Use the shared bounded refresh so custom-provider + registration cannot block boot. + */ + await refreshFusionModelRegistry(modelRegistry, { log: logFn }); } export async function reregisterCustomProviders( @@ -166,5 +173,5 @@ export async function reregisterCustomProviders( } } - await modelRegistry.refresh(); + await refreshFusionModelRegistry(modelRegistry, { log: logFn }); } diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 2325d6bb76..a6a0c3d5aa 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -2,6 +2,13 @@ export { AgentLogger, type AgentLoggerOptions, summarizeToolArgs } from "./agent export { reloadExemptTools, addToExemptTools, getExemptToolNames } from "./agent-action-gate.js"; export type { AgentActionGateContext } from "./agent-action-gate.js"; export { createFusionAuthStorage, createFusionModelRegistry } from "./auth-storage.js"; +export { + DEFAULT_MODEL_REGISTRY_REFRESH_TIMEOUT_MS, + refreshFusionModelRegistry, + type ModelRegistryRefreshOutcome, + type RefreshableModelRegistry, + type RefreshFusionModelRegistryOptions, +} from "./model-registry-refresh.js"; export { wrapAuthStorageWithApiKeyProviders, mergeAuthStorageReads, diff --git a/packages/engine/src/model-registry-refresh.ts b/packages/engine/src/model-registry-refresh.ts new file mode 100644 index 0000000000..d17505db96 --- /dev/null +++ b/packages/engine/src/model-registry-refresh.ts @@ -0,0 +1,85 @@ +/* +FNXC:ModelRegistry 2026-07-21-17:15: +pi 0.80.8+ ModelRegistry.refresh() delegates to ModelRuntime.reloadConfig() / +refresh(), which performs remote model-catalog fetches and availability checks +with no timeout on the post-create path. A hung provider catalog (observed as a +stuck HTTPS connection to Cloudflare while the TUI stayed on "Loading +extensions…") blocked fn dashboard / serve / daemon forever after extensions +had already finished loading. Bound every Fusion-owned await so startup always +progresses; createFusionModelRegistry already ran a 15s network refresh and +cached models remain usable. +*/ + +/** Default bound for Fusion-owned model-registry refresh awaits (matches ModelRuntime.create). */ +export const DEFAULT_MODEL_REGISTRY_REFRESH_TIMEOUT_MS = 15_000; + +export type ModelRegistryRefreshOutcome = "completed" | "timed_out" | "failed"; + +export type RefreshableModelRegistry = { + refresh: () => unknown; + modelRuntime?: { + refresh: (options?: { + allowNetwork?: boolean; + signal?: AbortSignal; + force?: boolean; + }) => Promise; + }; +}; + +export type RefreshFusionModelRegistryOptions = { + timeoutMs?: number; + /** When runtime is available, pass through to ModelRuntime.refresh. Default true. */ + allowNetwork?: boolean; + log?: (message: string) => void; +}; + +/** + * Await a model-registry refresh with a hard wall-clock bound. + * Prefers ModelRuntime.refresh({ signal }) when present so in-flight catalog + * fetches can abort; always races the full operation because forceRefreshAvailability + * inside ModelRuntime.refresh does not honor AbortSignal. + */ +export async function refreshFusionModelRegistry( + modelRegistry: RefreshableModelRegistry, + options: RefreshFusionModelRegistryOptions = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_MODEL_REGISTRY_REFRESH_TIMEOUT_MS; + const allowNetwork = options.allowNetwork ?? true; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const runtime = modelRegistry.modelRuntime; + const work = runtime + ? runtime.refresh({ allowNetwork, signal: controller.signal }) + : Promise.resolve(modelRegistry.refresh()); + + await Promise.race([ + work, + new Promise((_, reject) => { + const onAbort = () => { + reject(new Error(`Model registry refresh timed out after ${timeoutMs}ms`)); + }; + if (controller.signal.aborted) { + onAbort(); + return; + } + controller.signal.addEventListener("abort", onAbort, { once: true }); + }), + ]); + return "completed"; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const timedOut = controller.signal.aborted || /timed out/i.test(message); + if (timedOut) { + options.log?.( + `Model registry refresh timed out after ${timeoutMs}ms; continuing with cached models`, + ); + return "timed_out"; + } + options.log?.(`Model registry refresh failed: ${message}`); + return "failed"; + } finally { + clearTimeout(timer); + } +} diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index b60330625a..17165ac237 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -65,6 +65,7 @@ import { import { isContextLimitError } from "./context-limit-detector.js"; import { applyClaudeAcpEnable } from "./claude-acp-enable.js"; import { createFusionAuthStorage, createFusionModelRegistry } from "./auth-storage.js"; +import { refreshFusionModelRegistry } from "./model-registry-refresh.js"; import { piLog, extensionsLog } from "./logger.js"; import { readCustomProviders } from "./custom-providers.js"; import { buildCustomProviderModels } from "./custom-provider-registry.js"; @@ -1571,12 +1572,20 @@ async function registerExtensionProviders(cwd: string, modelRegistry: ModelRegis extensionsResult.runtime.pendingProviderRegistrations = []; mergeBuiltInZaiProviderModels(modelRegistry, (message) => extensionsLog.warn(message)); mergeBuiltInGrokProviderModels(modelRegistry, (message) => extensionsLog.warn(message)); - await modelRegistry.refresh(); + /* + FNXC:ModelRegistry 2026-07-21-17:15: + Bound post-extension refresh so a hung catalog fetch cannot stall agent session setup. + */ + await refreshFusionModelRegistry(modelRegistry, { + log: (message) => extensionsLog.warn(message), + }); } catch (error) { const message = error instanceof Error ? error.message : String(error); extensionsLog.error(`Failed to discover extensions: ${message}`); createExtensionRuntime(); - await modelRegistry.refresh(); + await refreshFusionModelRegistry(modelRegistry, { + log: (message) => extensionsLog.warn(message), + }); } } @@ -2168,7 +2177,9 @@ export async function createFnAgent(options: AgentOptions): Promise piLog.warn(`Failed to register custom provider "${provider.name}" (key=${registryKey}, id=${provider.id}, apiType=${provider.apiType}, baseUrl=${provider.baseUrl}): ${message}`); } } - await modelRegistry.refresh(); + await refreshFusionModelRegistry(modelRegistry, { + log: (message) => extensionsLog.warn(message), + }); mergeSupplementalAnthropicModels(modelRegistry, (message) => extensionsLog.warn(message)); /* * FNXC:ModelCatalog 2026-07-09-00:00: diff --git a/packages/engine/src/provider-registration.ts b/packages/engine/src/provider-registration.ts index 5085c7bb76..b4a0f2bf4f 100644 --- a/packages/engine/src/provider-registration.ts +++ b/packages/engine/src/provider-registration.ts @@ -28,6 +28,7 @@ import { type DashboardAuthStorage, } from "./provider-auth.js"; import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js"; +import { refreshFusionModelRegistry, type RefreshableModelRegistry } from "./model-registry-refresh.js"; export interface SeedDashboardProvidersStore { getGlobalSettingsStore(): { @@ -70,7 +71,14 @@ export async function seedDashboardProviders( mergeBuiltInZaiProviderModels(modelRegistry, (message) => log("extensions", message)); mergeBuiltInGrokProviderModels(modelRegistry, (message) => log("extensions", message)); - await modelRegistry.refresh(); + /* + FNXC:ModelRegistry 2026-07-21-17:15: + Bound the post-seed refresh so a hung remote catalog cannot leave desktop/CLI + startup stuck after built-in providers are already registered. + */ + await refreshFusionModelRegistry(modelRegistry as RefreshableModelRegistry, { + log: (message) => log("extensions", message), + }); try { const globalSettings = await store.getGlobalSettingsStore().getSettings();