fix(startup): bound model registry refresh so dashboard cannot hang

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.
This commit is contained in:
gsxdsm
2026-07-21 17:14:17 -07:00
parent 515d77cea8
commit 396090fc03
9 changed files with 214 additions and 14 deletions

View File

@@ -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({

View File

@@ -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({

View File

@@ -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({

View File

@@ -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);
});
});

View File

@@ -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<void>;
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 });
}

View File

@@ -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,

View File

@@ -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<unknown>;
};
};
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<ModelRegistryRefreshOutcome> {
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<never>((_, 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);
}
}

View File

@@ -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<AgentResult>
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:

View File

@@ -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();