fix(config-handler): add timeout + error boundary around loadAllPluginComponents (#1559)
This commit is contained in:
parent
1ae7d7d67e
commit
7ede8e04f0
@ -642,3 +642,123 @@ describe("Deadlock prevention - fetchAvailableModels must not receive client", (
|
|||||||
fetchSpy.mockRestore?.()
|
fetchSpy.mockRestore?.()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("config-handler plugin loading error boundary (#1559)", () => {
|
||||||
|
test("returns empty defaults when loadAllPluginComponents throws", async () => {
|
||||||
|
//#given
|
||||||
|
;(pluginLoader.loadAllPluginComponents as any).mockRestore?.()
|
||||||
|
spyOn(pluginLoader, "loadAllPluginComponents" as any).mockRejectedValue(new Error("crash"))
|
||||||
|
const pluginConfig: OhMyOpenCodeConfig = {}
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
model: "anthropic/claude-opus-4-6",
|
||||||
|
agent: {},
|
||||||
|
}
|
||||||
|
const handler = createConfigHandler({
|
||||||
|
ctx: { directory: "/tmp" },
|
||||||
|
pluginConfig,
|
||||||
|
modelCacheState: {
|
||||||
|
anthropicContext1MEnabled: false,
|
||||||
|
modelContextLimitsCache: new Map(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await handler(config)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(config.agent).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns empty defaults when loadAllPluginComponents times out", async () => {
|
||||||
|
//#given
|
||||||
|
;(pluginLoader.loadAllPluginComponents as any).mockRestore?.()
|
||||||
|
spyOn(pluginLoader, "loadAllPluginComponents" as any).mockImplementation(
|
||||||
|
() => new Promise(() => {})
|
||||||
|
)
|
||||||
|
const pluginConfig: OhMyOpenCodeConfig = {
|
||||||
|
experimental: { plugin_load_timeout_ms: 100 },
|
||||||
|
}
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
model: "anthropic/claude-opus-4-6",
|
||||||
|
agent: {},
|
||||||
|
}
|
||||||
|
const handler = createConfigHandler({
|
||||||
|
ctx: { directory: "/tmp" },
|
||||||
|
pluginConfig,
|
||||||
|
modelCacheState: {
|
||||||
|
anthropicContext1MEnabled: false,
|
||||||
|
modelContextLimitsCache: new Map(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await handler(config)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(config.agent).toBeDefined()
|
||||||
|
}, 5000)
|
||||||
|
|
||||||
|
test("logs error when loadAllPluginComponents fails", async () => {
|
||||||
|
//#given
|
||||||
|
;(pluginLoader.loadAllPluginComponents as any).mockRestore?.()
|
||||||
|
spyOn(pluginLoader, "loadAllPluginComponents" as any).mockRejectedValue(new Error("crash"))
|
||||||
|
const logSpy = shared.log as ReturnType<typeof spyOn>
|
||||||
|
const pluginConfig: OhMyOpenCodeConfig = {}
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
model: "anthropic/claude-opus-4-6",
|
||||||
|
agent: {},
|
||||||
|
}
|
||||||
|
const handler = createConfigHandler({
|
||||||
|
ctx: { directory: "/tmp" },
|
||||||
|
pluginConfig,
|
||||||
|
modelCacheState: {
|
||||||
|
anthropicContext1MEnabled: false,
|
||||||
|
modelContextLimitsCache: new Map(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await handler(config)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
const logCalls = logSpy.mock.calls.map((c: unknown[]) => c[0])
|
||||||
|
const hasPluginFailureLog = logCalls.some(
|
||||||
|
(msg: string) => typeof msg === "string" && msg.includes("Plugin loading failed")
|
||||||
|
)
|
||||||
|
expect(hasPluginFailureLog).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("passes through plugin data on successful load (identity test)", async () => {
|
||||||
|
//#given
|
||||||
|
;(pluginLoader.loadAllPluginComponents as any).mockRestore?.()
|
||||||
|
spyOn(pluginLoader, "loadAllPluginComponents" as any).mockResolvedValue({
|
||||||
|
commands: { "test-cmd": { description: "test", template: "test" } },
|
||||||
|
skills: {},
|
||||||
|
agents: {},
|
||||||
|
mcpServers: {},
|
||||||
|
hooksConfigs: [],
|
||||||
|
plugins: [{ name: "test-plugin", version: "1.0.0" }],
|
||||||
|
errors: [],
|
||||||
|
})
|
||||||
|
const pluginConfig: OhMyOpenCodeConfig = {}
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
model: "anthropic/claude-opus-4-6",
|
||||||
|
agent: {},
|
||||||
|
}
|
||||||
|
const handler = createConfigHandler({
|
||||||
|
ctx: { directory: "/tmp" },
|
||||||
|
pluginConfig,
|
||||||
|
modelCacheState: {
|
||||||
|
anthropicContext1MEnabled: false,
|
||||||
|
modelContextLimitsCache: new Map(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await handler(config)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
const commands = config.command as Record<string, unknown>
|
||||||
|
expect(commands["test-cmd"]).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -25,7 +25,7 @@ import { loadMcpConfigs } from "../features/claude-code-mcp-loader";
|
|||||||
import { loadAllPluginComponents } from "../features/claude-code-plugin-loader";
|
import { loadAllPluginComponents } from "../features/claude-code-plugin-loader";
|
||||||
import { createBuiltinMcps } from "../mcp";
|
import { createBuiltinMcps } from "../mcp";
|
||||||
import type { OhMyOpenCodeConfig } from "../config";
|
import type { OhMyOpenCodeConfig } from "../config";
|
||||||
import { log, fetchAvailableModels, readConnectedProvidersCache, resolveModelPipeline } from "../shared";
|
import { log, fetchAvailableModels, readConnectedProvidersCache, resolveModelPipeline, addConfigLoadError } from "../shared";
|
||||||
import { getOpenCodeConfigPaths } from "../shared/opencode-config-dir";
|
import { getOpenCodeConfigPaths } from "../shared/opencode-config-dir";
|
||||||
import { migrateAgentConfig } from "../shared/permission-compat";
|
import { migrateAgentConfig } from "../shared/permission-compat";
|
||||||
import { AGENT_NAME_MAP } from "../shared/migration";
|
import { AGENT_NAME_MAP } from "../shared/migration";
|
||||||
@ -104,19 +104,40 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginComponents = (pluginConfig.claude_code?.plugins ?? true)
|
const emptyPluginDefaults = {
|
||||||
? await loadAllPluginComponents({
|
commands: {},
|
||||||
enabledPluginsOverride: pluginConfig.claude_code?.plugins_override,
|
skills: {},
|
||||||
})
|
agents: {},
|
||||||
: {
|
mcpServers: {},
|
||||||
commands: {},
|
hooksConfigs: [] as { hooks?: Record<string, unknown> }[],
|
||||||
skills: {},
|
plugins: [] as { name: string; version: string }[],
|
||||||
agents: {},
|
errors: [] as { pluginKey: string; installPath: string; error: string }[],
|
||||||
mcpServers: {},
|
};
|
||||||
hooksConfigs: [],
|
|
||||||
plugins: [],
|
let pluginComponents: typeof emptyPluginDefaults;
|
||||||
errors: [],
|
const pluginsEnabled = pluginConfig.claude_code?.plugins ?? true;
|
||||||
};
|
|
||||||
|
if (pluginsEnabled) {
|
||||||
|
const timeoutMs = pluginConfig.experimental?.plugin_load_timeout_ms ?? 10000;
|
||||||
|
try {
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`Plugin loading timed out after ${timeoutMs}ms`)), timeoutMs)
|
||||||
|
);
|
||||||
|
pluginComponents = await Promise.race([
|
||||||
|
loadAllPluginComponents({
|
||||||
|
enabledPluginsOverride: pluginConfig.claude_code?.plugins_override,
|
||||||
|
}),
|
||||||
|
timeoutPromise,
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
log("[config-handler] Plugin loading failed", { error: errorMessage });
|
||||||
|
addConfigLoadError({ path: "plugin-loading", error: errorMessage });
|
||||||
|
pluginComponents = emptyPluginDefaults;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pluginComponents = emptyPluginDefaults;
|
||||||
|
}
|
||||||
|
|
||||||
if (pluginComponents.plugins.length > 0) {
|
if (pluginComponents.plugins.length > 0) {
|
||||||
log(`Loaded ${pluginComponents.plugins.length} Claude Code plugins`, {
|
log(`Loaded ${pluginComponents.plugins.length} Claude Code plugins`, {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user