diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 94c8907..5a8ac04 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -76,3 +76,4 @@ - [ux-ai-profiles-opencode-persistence-list-coherence](ux-ai-profiles-opencode-persistence-list-coherence.md) — memory note ux-ai-profiles-opencode-persistence-list-coherence - [ticket113-controlled-args-field-rootcause](ticket113-controlled-args-field-rootcause.md) — memory note ticket113-controlled-args-field-rootcause - [ticket120-hello-plugin-recurrence-investigation-angle](ticket120-hello-plugin-recurrence-investigation-angle.md) — memory note ticket120-hello-plugin-recurrence-investigation-angle +- [ticket120-hello-plugin-blackscreen-recurrence](ticket120-hello-plugin-blackscreen-recurrence.md) — memory note ticket120-hello-plugin-blackscreen-recurrence diff --git a/.ideai/memory/ticket120-hello-plugin-blackscreen-recurrence.md b/.ideai/memory/ticket120-hello-plugin-blackscreen-recurrence.md new file mode 100644 index 0000000..c52ccca --- /dev/null +++ b/.ideai/memory/ticket120-hello-plugin-blackscreen-recurrence.md @@ -0,0 +1,26 @@ +--- +name: ticket120-hello-plugin-blackscreen-recurrence +description: memory note ticket120-hello-plugin-blackscreen-recurrence +metadata: + type: project +--- +# Récidive écran noir hello-plugin malgré 3 fixes mergés + +Le bug "écran noir à l'installation de hello-plugin" (#120, encore open) a déjà survécu à 3 correctifs mergés dans develop — traiter le prochain lot comme recherche de root cause, pas comme patch symptomatique de plus. + +## Constat + +Le ticket #120 ("Réinvestiguer l'installation de hello-plugin: écran noir / perte d'affichage IdeA") est toujours **`open`** au 2026-08-01, alors que **trois** correctifs distincts ont déjà été mergés dans `develop` sur le même symptôme : + +1. `fix/ticket116-plugin-install-black-screen` → mergé `f6685aa` (ticket #116, closed) — "isoler crash plugin hello-plugin + erreur explicite UI" +2. `feature/hello-plugin-manifest-fix-and-fixture-tests` → mergé `6270f98` — "aligne le manifeste hello-plugin sur le schéma backend + fixture de test" +3. `feature/ticket120-hello-plugin-blackscreen-reinvestigation` → mergé `e741f76` (aujourd'hui) — "isolation plugin invalide + réconciliation MCP durcie + loader export default/cleanup" + +Et le bug revient une 4e fois, ce qui a motivé la relance de #120 le 2026-08-01 sur la branche `feature/ticket120-hello-plugin-blackscreen-crash-diagnostics`. + +**Why** : chaque fix précédent a visiblement traité un symptôme observable (crash isolation, manifeste, réconciliation MCP, loader export) sans que la cause racine soit couverte — sinon le bug ne reviendrait pas. Fermer #120 à chaque merge sans validation e2e réelle post-merge semble être le trou du cycle. + +**How to apply** : +- Avant tout nouveau fix sur ce symptôme, lire l'historique des 3 tentatives ci-dessus pour ne pas répéter une piste déjà explorée et retombée. +- Ne pas fermer #120 au merge — seulement après validation réelle de l'installation du plugin de bout en bout (voir [[git-owns-commit-merge-decisions]] pour la règle générale de non-merge sans tests verts, qui s'applique ici avec une vigilance renforcée). +- La dette de diagnostic crash/logs est traitée dans le même lot que ce 4e essai (branche `feature/ticket120-hello-plugin-blackscreen-crash-diagnostics`), précisément pour éviter un 5e patch aveugle. \ No newline at end of file diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 98df2bb..0787119 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -47,6 +47,7 @@ use uuid::Uuid; use state::AppState; static EXIT_GUARD_CONFIRMED: AtomicBool = AtomicBool::new(false); +static PANIC_HOOK_INSTALLED: AtomicBool = AtomicBool::new(false); #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum MainCloseAction { @@ -69,6 +70,33 @@ fn should_install_exit_guard(window_label: &str) -> bool { window_label == "main" } +fn install_panic_diagnostics_hook() { + if PANIC_HOOK_INSTALLED.swap(true, Ordering::SeqCst) { + return; + } + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let message = info + .payload() + .downcast_ref::<&str>() + .copied() + .or_else(|| info.payload().downcast_ref::().map(String::as_str)) + .unwrap_or(""); + let location = info + .location() + .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())) + .unwrap_or_else(|| "".to_owned()); + let thread = std::thread::current(); + let thread_name = thread.name().unwrap_or(""); + application::diag!("[panic] thread={thread_name} location={location} message={message}"); + application::diag!( + "[panic] backtrace:\n{}", + std::backtrace::Backtrace::force_capture() + ); + previous(info); + })); +} + fn apply_main_close_decision( guard: application::AppExitWorkGuardState, already_confirmed: bool, @@ -151,6 +179,7 @@ pub fn run() { // click-launched AppImage (whose stderr is otherwise discarded). Best-effort: // if the file can't be opened the beacons simply stay on stderr. application::diag::set_log_path(app_data_dir.join("logs").join("idea.log")); + install_panic_diagnostics_hook(); application::diag!("[startup] IdeA launched; diagnostics log armed"); let app_state = AppState::build_with_resource_dir(app_data_dir, resource_dir); diff --git a/crates/app-tauri/src/plugins.rs b/crates/app-tauri/src/plugins.rs index c748655..0759d6e 100644 --- a/crates/app-tauri/src/plugins.rs +++ b/crates/app-tauri/src/plugins.rs @@ -57,12 +57,28 @@ pub async fn plugin_install_from_archive( path: String, state: State<'_, AppState>, ) -> Result { - state + application::diag!("[plugins] install archive start path={path}"); + let result = state .install_plugin_from_archive .execute(path) .await .map(PluginInstallResultDto::from) - .map_err(ErrorDto::from) + .map_err(ErrorDto::from); + match &result { + Ok(result) => application::diag!( + "[plugins] install archive ok plugin={} version={} hash={} lifecycle={:?}", + result.plugin.id, + result.plugin.version, + result.review.content_hash, + result.plugin.lifecycle_state + ), + Err(err) => application::diag!( + "[plugins] install archive failed code={} message={}", + err.code, + err.message + ), + } + result } /// Installs a plugin from a local directory snapshot. @@ -71,12 +87,28 @@ pub async fn plugin_install_from_directory( path: String, state: State<'_, AppState>, ) -> Result { - state + application::diag!("[plugins] install directory start path={path}"); + let result = state .install_plugin_from_directory .execute(path) .await .map(PluginInstallResultDto::from) - .map_err(ErrorDto::from) + .map_err(ErrorDto::from); + match &result { + Ok(result) => application::diag!( + "[plugins] install directory ok plugin={} version={} hash={} lifecycle={:?}", + result.plugin.id, + result.plugin.version, + result.review.content_hash, + result.plugin.lifecycle_state + ), + Err(err) => application::diag!( + "[plugins] install directory failed code={} message={}", + err.code, + err.message + ), + } + result } /// Enables or disables a plugin. @@ -145,13 +177,30 @@ pub fn plugin_asset_protocol( app: &AppHandle, request: http::Request>, ) -> Response> { + let uri = request.uri().to_string(); match plugin_asset_response(app, request) { - Ok(response) => response, - Err((status, message)) => Response::builder() - .status(status) - .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") - .body(message.into_bytes()) - .expect("valid protocol error response"), + Ok(response) => { + application::diag!( + "[plugins] asset ok uri={} status={} bytes={}", + uri, + response.status(), + response.body().len() + ); + response + } + Err((status, message)) => { + application::diag!( + "[plugins] asset failed uri={uri} status={status} message={message}" + ); + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(message.into_bytes()) + .unwrap_or_else(|e| { + application::diag!("[plugins] asset error response build failed: {e}"); + Response::new(Vec::new()) + }) + } } } diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index 0a5d314..220106c 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -502,10 +502,18 @@ async fn install_from_staged( ) -> Result { let review = review_staged(packages, validator, &staged).await?; let plugin_id = review.manifest.id.clone(); + crate::diag!( + "[plugins] install staged reviewed plugin={} version={} hash={} source={}", + plugin_id.as_str(), + review.manifest.version.as_str(), + review.content_hash, + review.source.kind() + ); packages .commit_install(staged, &plugin_id) .await .map_err(map_store)?; + crate::diag!("[plugins] install committed plugin={}", plugin_id.as_str()); let mut registry = registry_store.load_registry().await.map_err(map_registry)?; let mut entry = PluginRegistryEntry { id: plugin_id.clone(), @@ -530,6 +538,11 @@ async fn install_from_staged( .save_registry(®istry) .await .map_err(map_registry)?; + crate::diag!( + "[plugins] install registry saved plugin={} lifecycle={:?}", + plugin_id.as_str(), + entry.lifecycle_state + ); events.publish(DomainEvent::PluginInstalled { plugin_id: plugin_id.clone(), version: review.manifest.version.clone(), @@ -537,7 +550,21 @@ async fn install_from_staged( let (active_servers, invalid_plugins) = active_mcp_specs(packages, validator, ®istry).await?; persist_invalid_runtime_plugins(registry_store, &mut registry, invalid_plugins).await; - let _ = mcp.reconcile(active_servers).await; + let active_server_count = active_servers.len(); + match mcp.reconcile(active_servers).await { + Ok(statuses) => crate::diag!( + "[plugins] install MCP reconcile ok plugin={} requested={} statuses={}", + plugin_id.as_str(), + active_server_count, + statuses.servers.len() + ), + Err(err) => crate::diag!( + "[plugins] install MCP reconcile failed plugin={} requested={} error={}", + plugin_id.as_str(), + active_server_count, + err + ), + } let admin = admin_from_descriptor( PluginDescriptor { manifest: review.manifest.clone(), @@ -846,7 +873,19 @@ impl ReconcilePluginMcpServers { active_mcp_specs(self.packages.as_ref(), self.validator.as_ref(), ®istry).await?; persist_invalid_runtime_plugins(self.registry.as_ref(), &mut registry, invalid_plugins) .await; - self.mcp.reconcile(specs).await.map_err(map_mcp) + let requested = specs.len(); + let result = self.mcp.reconcile(specs).await.map_err(map_mcp); + match &result { + Ok(statuses) => crate::diag!( + "[plugins] MCP reconcile ok requested={} statuses={}", + requested, + statuses.servers.len() + ), + Err(err) => { + crate::diag!("[plugins] MCP reconcile failed requested={requested} error={err}") + } + } + result } } diff --git a/crates/infrastructure/src/plugin/mod.rs b/crates/infrastructure/src/plugin/mod.rs index 43732b0..6277db8 100644 --- a/crates/infrastructure/src/plugin/mod.rs +++ b/crates/infrastructure/src/plugin/mod.rs @@ -5,7 +5,7 @@ use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; use std::process::Stdio; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{SystemTime, UNIX_EPOCH}; use async_trait::async_trait; @@ -386,6 +386,8 @@ trait ExternalMcpServerBridge: Send + Sync { ) -> Result, PluginMcpError>; } +type ExternalMcpChildren = HashMap>; + struct ProcessMcpServerHandle { child: Child, } @@ -433,7 +435,7 @@ impl ExternalMcpServerBridge for StdioExternalMcpServerBridge { /// External process supervisor for plugin MCP servers. pub struct ExternalMcpPluginSupervisor { bridge: Arc, - children: Mutex>>, + children: Mutex, } impl ExternalMcpPluginSupervisor { @@ -453,6 +455,12 @@ impl ExternalMcpPluginSupervisor { children: Mutex::new(HashMap::new()), } } + + fn children(&self) -> Result, PluginMcpError> { + self.children + .lock() + .map_err(|_| PluginMcpError::Process("plugin mcp supervisor mutex poisoned".to_owned())) + } } impl Default for ExternalMcpPluginSupervisor { @@ -469,10 +477,7 @@ impl PluginMcpSupervisor for ExternalMcpPluginSupervisor { ) -> Result { let desired: HashSet = active_servers.iter().map(|s| s.identity.clone()).collect(); let to_stop = { - let children = self - .children - .lock() - .expect("plugin mcp supervisor poisoned"); + let children = self.children()?; children .keys() .filter(|id| !desired.contains(*id)) @@ -480,22 +485,14 @@ impl PluginMcpSupervisor for ExternalMcpPluginSupervisor { .collect::>() }; for id in to_stop { - let child = self - .children - .lock() - .expect("plugin mcp supervisor poisoned") - .remove(&id); + let child = self.children()?.remove(&id); if let Some(mut child) = child { let _ = child.stop().await; } } let mut statuses = Vec::new(); for spec in active_servers { - let already = self - .children - .lock() - .expect("plugin mcp supervisor poisoned") - .contains_key(&spec.identity); + let already = self.children()?.contains_key(&spec.identity); if already { statuses.push(PluginMcpStatus { identity: spec.identity, @@ -506,10 +503,12 @@ impl PluginMcpSupervisor for ExternalMcpPluginSupervisor { } match self.bridge.start(&spec).await { Ok(handle) => { - self.children - .lock() - .expect("plugin mcp supervisor poisoned") - .insert(spec.identity.clone(), handle); + match self.children() { + Ok(mut children) => { + children.insert(spec.identity.clone(), handle); + } + Err(err) => return Err(err), + } statuses.push(PluginMcpStatus { identity: spec.identity, running: true, @@ -529,10 +528,7 @@ impl PluginMcpSupervisor for ExternalMcpPluginSupervisor { async fn stop_plugin(&self, plugin_id: &PluginId) -> Result<(), PluginMcpError> { let prefix = format!("plugin:{}:", plugin_id.as_str()); let ids = { - let children = self - .children - .lock() - .expect("plugin mcp supervisor poisoned"); + let children = self.children()?; children .keys() .filter(|id| id.starts_with(&prefix)) @@ -540,11 +536,7 @@ impl PluginMcpSupervisor for ExternalMcpPluginSupervisor { .collect::>() }; for id in ids { - let child = self - .children - .lock() - .expect("plugin mcp supervisor poisoned") - .remove(&id); + let child = self.children()?.remove(&id); if let Some(mut child) = child { child.stop().await?; } @@ -751,4 +743,20 @@ mod tests { assert_eq!(statuses.servers[0].identity, other.identity); assert_eq!(bridge.started.lock().unwrap().len(), 2); } + + #[tokio::test] + async fn supervisor_poisoned_registry_returns_typed_error() { + let supervisor = ExternalMcpPluginSupervisor::new(); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = supervisor.children.lock().unwrap(); + panic!("poison plugin mcp supervisor registry"); + })); + + let err = supervisor.reconcile(Vec::new()).await.unwrap_err(); + + assert_eq!( + err, + PluginMcpError::Process("plugin mcp supervisor mutex poisoned".to_owned()) + ); + } } diff --git a/frontend/src/app/RootErrorBoundary.test.tsx b/frontend/src/app/RootErrorBoundary.test.tsx new file mode 100644 index 0000000..55a6de4 --- /dev/null +++ b/frontend/src/app/RootErrorBoundary.test.tsx @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary"; + +function ThrowingView(): JSX.Element { + throw new Error("boom from render"); +} + +describe("RootErrorBoundary", () => { + it("renders a visible fallback when the app render tree throws", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + render( + + + , + ); + + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.getByText("IdeA a rencontré une erreur d'affichage.")).toBeTruthy(); + expect(screen.getByText(/boom from render/)).toBeTruthy(); + } finally { + consoleError.mockRestore(); + } + }); + + it("offers a reload action from the fallback", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const reload = vi.fn(); + const originalLocation = window.location; + try { + Object.defineProperty(window, "location", { + configurable: true, + value: { ...originalLocation, reload }, + }); + + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Recharger" })); + expect(reload).toHaveBeenCalledOnce(); + } finally { + Object.defineProperty(window, "location", { + configurable: true, + value: originalLocation, + }); + consoleError.mockRestore(); + } + }); +}); + +describe("installGlobalErrorLogging", () => { + it("installs global error logging only once", () => { + const addEventListener = vi.spyOn(window, "addEventListener"); + const previousFlag = window.__IDEA_GLOBAL_ERROR_LOGGING__; + try { + window.__IDEA_GLOBAL_ERROR_LOGGING__ = undefined; + installGlobalErrorLogging(); + installGlobalErrorLogging(); + + expect( + addEventListener.mock.calls.filter(([event]) => event === "error"), + ).toHaveLength(1); + expect( + addEventListener.mock.calls.filter(([event]) => event === "unhandledrejection"), + ).toHaveLength(1); + } finally { + window.__IDEA_GLOBAL_ERROR_LOGGING__ = previousFlag; + addEventListener.mockRestore(); + } + }); +}); diff --git a/frontend/src/app/RootErrorBoundary.tsx b/frontend/src/app/RootErrorBoundary.tsx new file mode 100644 index 0000000..802405b --- /dev/null +++ b/frontend/src/app/RootErrorBoundary.tsx @@ -0,0 +1,102 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; + +const UNKNOWN_ERROR = "Erreur frontend inconnue"; + +export function installGlobalErrorLogging(): void { + const globalWindow = globalThis.window; + if (!globalWindow || globalWindow.__IDEA_GLOBAL_ERROR_LOGGING__) return; + globalWindow.__IDEA_GLOBAL_ERROR_LOGGING__ = true; + + globalWindow.addEventListener("error", (event) => { + console.error("[idea-ui] uncaught error", { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + error: event.error, + }); + }); + + globalWindow.addEventListener("unhandledrejection", (event) => { + console.error("[idea-ui] unhandled promise rejection", event.reason); + }); +} + +interface RootErrorBoundaryProps { + children: ReactNode; +} + +interface RootErrorBoundaryState { + error: Error | null; + componentStack: string | null; +} + +export class RootErrorBoundary extends Component< + RootErrorBoundaryProps, + RootErrorBoundaryState +> { + state: RootErrorBoundaryState = { + error: null, + componentStack: null, + }; + + static getDerivedStateFromError(error: Error): RootErrorBoundaryState { + return { error, componentStack: null }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error("[idea-ui] root render failed", error, errorInfo); + this.setState({ componentStack: errorInfo.componentStack ?? null }); + } + + render(): ReactNode { + const { error, componentStack } = this.state; + if (!error) return this.props.children; + + const message = error.message || UNKNOWN_ERROR; + const details = [error.stack, componentStack].filter(Boolean).join("\n\n"); + + return ( +
+
+
+

+ Interface interrompue +

+

+ IdeA a rencontré une erreur d'affichage. +

+

+ L'application reste ouverte. Rechargez la fenêtre après avoir copié + le diagnostic si le problème doit être investigué. +

+
+ +
+            {details || message}
+          
+ +
+ +
+
+
+ ); + } +} + +declare global { + interface Window { + __IDEA_GLOBAL_ERROR_LOGGING__?: boolean; + } +} diff --git a/frontend/src/app/main.tsx b/frontend/src/app/main.tsx index d2985d8..ef62dd4 100644 --- a/frontend/src/app/main.tsx +++ b/frontend/src/app/main.tsx @@ -7,6 +7,7 @@ import { App } from "./App"; import { ViewWindow, parseViewWindowParams } from "./ViewWindow"; import { WebApp } from "@/features/web"; import { DIProvider, resolveTransport } from "./di"; +import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary"; import "@/shared/styles/theme.css"; const root = document.getElementById("root"); @@ -32,16 +33,20 @@ const viewParams = parseViewWindowParams(window.location.search); // the full `App` (or a detached view window). Detached windows are desktop-only. const isWeb = resolveTransport() === "http"; +installGlobalErrorLogging(); + ReactDOM.createRoot(root).render( - - {isWeb ? ( - - ) : viewParams ? ( - - ) : ( - - )} - + + + {isWeb ? ( + + ) : viewParams ? ( + + ) : ( + + )} + + , ); diff --git a/frontend/src/plugins/runtime/loader.test.ts b/frontend/src/plugins/runtime/loader.test.ts index 79329d6..5485782 100644 --- a/frontend/src/plugins/runtime/loader.test.ts +++ b/frontend/src/plugins/runtime/loader.test.ts @@ -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 diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts index 77d4803..788aa36 100644 --- a/frontend/src/plugins/runtime/loader.ts +++ b/frontend/src/plugins/runtime/loader.ts @@ -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( + promise: Promise, + timeoutMs: number, + label: string, +): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, 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, ): 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 { const registry = new PluginRuntimeRegistry(); const failures: PluginLoadFailure[] = []; + const resolvedOptions: Required = { + 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);