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:
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
54
packages/engine/src/__tests__/model-registry-refresh.test.ts
Normal file
54
packages/engine/src/__tests__/model-registry-refresh.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
85
packages/engine/src/model-registry-refresh.ts
Normal file
85
packages/engine/src/model-registry-refresh.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user