fix(frontend): écran noir plutôt que muet sur crash de plugin ou d'activation figée
Complète le diagnostic backend (#120) côté frontend : un crash React non capturé (ex. plugin cassant le rendu) laissait un écran noir sans trace, et une activation de plugin qui ne se résout jamais (promesse infinie) bloquait le chargement sans échouer. Ajoute RootErrorBoundary + logging d'erreurs globales autour de l'arbre React, et un timeout sur l'import/l'activation de chaque plugin dans loadPlugins pour transformer un hang silencieux en échec explicite et diagnosticable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -305,6 +305,28 @@ describe("loadPlugins", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("collects a failure when plugin activation does not settle", async () => {
|
||||
const bundle = dataUrl(`
|
||||
export function activate() {
|
||||
return new Promise(() => {});
|
||||
}
|
||||
`);
|
||||
|
||||
const { registry, failures } = await loadPlugins(
|
||||
[entry({ id: "dev.acme.hung", displayName: "Hung", bundleUrl: bundle })],
|
||||
gateways,
|
||||
{ timeoutMs: 10 },
|
||||
);
|
||||
|
||||
expect(registry.list()).toEqual([]);
|
||||
expect(failures).toEqual([
|
||||
{
|
||||
pluginId: "dev.acme.hung",
|
||||
reason: "activating plugin timed out after 10 ms",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("only loads what the catalog contains — a disabled/absent plugin is simply never in it", async () => {
|
||||
// The backend contract (carnet §1.3) filters the catalog to
|
||||
// `enabled && !pendingUninstall` before the loader ever sees it; the
|
||||
|
||||
@ -72,6 +72,32 @@ export interface PluginLoadResult {
|
||||
failures: PluginLoadFailure[];
|
||||
}
|
||||
|
||||
export interface PluginLoadOptions {
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PLUGIN_LOAD_TIMEOUT_MS = 10_000;
|
||||
|
||||
async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
label: string,
|
||||
): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${timeoutMs} ms`));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule {
|
||||
return (
|
||||
typeof mod === "object" &&
|
||||
@ -170,6 +196,7 @@ async function disposeAll(disposables: Disposable[], activation?: void | PluginA
|
||||
async function loadOne(
|
||||
entry: PluginRuntimePlugin,
|
||||
gateways: PluginGatewaySet,
|
||||
options: Required<PluginLoadOptions>,
|
||||
): Promise<{ plugin: LoadedPlugin } | { failure: PluginLoadFailure }> {
|
||||
const entryObject = objectOrEmpty(entry);
|
||||
const pluginId = safePluginId(entry);
|
||||
@ -186,7 +213,13 @@ async function loadOne(
|
||||
throw new Error("missing plugin bundle URL");
|
||||
}
|
||||
|
||||
const mod = resolveIdeaPluginModule(await import(/* @vite-ignore */ bundleUrl));
|
||||
const mod = resolveIdeaPluginModule(
|
||||
await withTimeout(
|
||||
import(/* @vite-ignore */ bundleUrl),
|
||||
options.timeoutMs,
|
||||
"importing plugin bundle",
|
||||
),
|
||||
);
|
||||
if (!mod) {
|
||||
return {
|
||||
failure: {
|
||||
@ -215,7 +248,11 @@ async function loadOne(
|
||||
...gateways,
|
||||
};
|
||||
|
||||
activation = await mod.activate(ctx);
|
||||
activation = await withTimeout(
|
||||
Promise.resolve(mod.activate(ctx)),
|
||||
options.timeoutMs,
|
||||
"activating plugin",
|
||||
);
|
||||
|
||||
const plugin: LoadedPlugin = {
|
||||
pluginId,
|
||||
@ -251,12 +288,18 @@ async function loadOne(
|
||||
export async function loadPlugins(
|
||||
catalogPlugins: PluginRuntimePlugin[],
|
||||
gateways: PluginGatewaySet,
|
||||
options: PluginLoadOptions = {},
|
||||
): Promise<PluginLoadResult> {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
const failures: PluginLoadFailure[] = [];
|
||||
const resolvedOptions: Required<PluginLoadOptions> = {
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_PLUGIN_LOAD_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
const entries = Array.isArray(catalogPlugins) ? catalogPlugins : [];
|
||||
const results = await Promise.all(entries.map((entry) => loadOne(entry, gateways)));
|
||||
const results = await Promise.all(
|
||||
entries.map((entry) => loadOne(entry, gateways, resolvedOptions)),
|
||||
);
|
||||
for (const result of results) {
|
||||
if ("failure" in result) failures.push(result.failure);
|
||||
else registry.add(result.plugin);
|
||||
|
||||
Reference in New Issue
Block a user