merge feature/ticket120-hello-plugin-blackscreen-crash-diagnostics dans develop (#120: diagnostics crash minimales backend + garde-fous frontend contre l'écran noir)
This commit is contained in:
@ -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
|
- [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
|
- [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-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
|
||||||
|
|||||||
@ -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.
|
||||||
@ -47,6 +47,7 @@ use uuid::Uuid;
|
|||||||
use state::AppState;
|
use state::AppState;
|
||||||
|
|
||||||
static EXIT_GUARD_CONFIRMED: AtomicBool = AtomicBool::new(false);
|
static EXIT_GUARD_CONFIRMED: AtomicBool = AtomicBool::new(false);
|
||||||
|
static PANIC_HOOK_INSTALLED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum MainCloseAction {
|
enum MainCloseAction {
|
||||||
@ -69,6 +70,33 @@ fn should_install_exit_guard(window_label: &str) -> bool {
|
|||||||
window_label == "main"
|
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::<String>().map(String::as_str))
|
||||||
|
.unwrap_or("<non-string panic payload>");
|
||||||
|
let location = info
|
||||||
|
.location()
|
||||||
|
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
|
||||||
|
.unwrap_or_else(|| "<unknown>".to_owned());
|
||||||
|
let thread = std::thread::current();
|
||||||
|
let thread_name = thread.name().unwrap_or("<unnamed>");
|
||||||
|
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(
|
fn apply_main_close_decision(
|
||||||
guard: application::AppExitWorkGuardState,
|
guard: application::AppExitWorkGuardState,
|
||||||
already_confirmed: bool,
|
already_confirmed: bool,
|
||||||
@ -151,6 +179,7 @@ pub fn run() {
|
|||||||
// click-launched AppImage (whose stderr is otherwise discarded). Best-effort:
|
// click-launched AppImage (whose stderr is otherwise discarded). Best-effort:
|
||||||
// if the file can't be opened the beacons simply stay on stderr.
|
// 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"));
|
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");
|
application::diag!("[startup] IdeA launched; diagnostics log armed");
|
||||||
let app_state = AppState::build_with_resource_dir(app_data_dir, resource_dir);
|
let app_state = AppState::build_with_resource_dir(app_data_dir, resource_dir);
|
||||||
|
|
||||||
|
|||||||
@ -57,12 +57,28 @@ pub async fn plugin_install_from_archive(
|
|||||||
path: String,
|
path: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<PluginInstallResultDto, ErrorDto> {
|
) -> Result<PluginInstallResultDto, ErrorDto> {
|
||||||
state
|
application::diag!("[plugins] install archive start path={path}");
|
||||||
|
let result = state
|
||||||
.install_plugin_from_archive
|
.install_plugin_from_archive
|
||||||
.execute(path)
|
.execute(path)
|
||||||
.await
|
.await
|
||||||
.map(PluginInstallResultDto::from)
|
.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.
|
/// Installs a plugin from a local directory snapshot.
|
||||||
@ -71,12 +87,28 @@ pub async fn plugin_install_from_directory(
|
|||||||
path: String,
|
path: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<PluginInstallResultDto, ErrorDto> {
|
) -> Result<PluginInstallResultDto, ErrorDto> {
|
||||||
state
|
application::diag!("[plugins] install directory start path={path}");
|
||||||
|
let result = state
|
||||||
.install_plugin_from_directory
|
.install_plugin_from_directory
|
||||||
.execute(path)
|
.execute(path)
|
||||||
.await
|
.await
|
||||||
.map(PluginInstallResultDto::from)
|
.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.
|
/// Enables or disables a plugin.
|
||||||
@ -145,13 +177,30 @@ pub fn plugin_asset_protocol(
|
|||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
request: http::Request<Vec<u8>>,
|
request: http::Request<Vec<u8>>,
|
||||||
) -> Response<Vec<u8>> {
|
) -> Response<Vec<u8>> {
|
||||||
|
let uri = request.uri().to_string();
|
||||||
match plugin_asset_response(app, request) {
|
match plugin_asset_response(app, request) {
|
||||||
Ok(response) => response,
|
Ok(response) => {
|
||||||
Err((status, message)) => Response::builder()
|
application::diag!(
|
||||||
.status(status)
|
"[plugins] asset ok uri={} status={} bytes={}",
|
||||||
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
uri,
|
||||||
.body(message.into_bytes())
|
response.status(),
|
||||||
.expect("valid protocol error response"),
|
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())
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -502,10 +502,18 @@ async fn install_from_staged(
|
|||||||
) -> Result<PluginInstallResult, AppError> {
|
) -> Result<PluginInstallResult, AppError> {
|
||||||
let review = review_staged(packages, validator, &staged).await?;
|
let review = review_staged(packages, validator, &staged).await?;
|
||||||
let plugin_id = review.manifest.id.clone();
|
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
|
packages
|
||||||
.commit_install(staged, &plugin_id)
|
.commit_install(staged, &plugin_id)
|
||||||
.await
|
.await
|
||||||
.map_err(map_store)?;
|
.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 registry = registry_store.load_registry().await.map_err(map_registry)?;
|
||||||
let mut entry = PluginRegistryEntry {
|
let mut entry = PluginRegistryEntry {
|
||||||
id: plugin_id.clone(),
|
id: plugin_id.clone(),
|
||||||
@ -530,6 +538,11 @@ async fn install_from_staged(
|
|||||||
.save_registry(®istry)
|
.save_registry(®istry)
|
||||||
.await
|
.await
|
||||||
.map_err(map_registry)?;
|
.map_err(map_registry)?;
|
||||||
|
crate::diag!(
|
||||||
|
"[plugins] install registry saved plugin={} lifecycle={:?}",
|
||||||
|
plugin_id.as_str(),
|
||||||
|
entry.lifecycle_state
|
||||||
|
);
|
||||||
events.publish(DomainEvent::PluginInstalled {
|
events.publish(DomainEvent::PluginInstalled {
|
||||||
plugin_id: plugin_id.clone(),
|
plugin_id: plugin_id.clone(),
|
||||||
version: review.manifest.version.clone(),
|
version: review.manifest.version.clone(),
|
||||||
@ -537,7 +550,21 @@ async fn install_from_staged(
|
|||||||
let (active_servers, invalid_plugins) =
|
let (active_servers, invalid_plugins) =
|
||||||
active_mcp_specs(packages, validator, ®istry).await?;
|
active_mcp_specs(packages, validator, ®istry).await?;
|
||||||
persist_invalid_runtime_plugins(registry_store, &mut registry, invalid_plugins).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(
|
let admin = admin_from_descriptor(
|
||||||
PluginDescriptor {
|
PluginDescriptor {
|
||||||
manifest: review.manifest.clone(),
|
manifest: review.manifest.clone(),
|
||||||
@ -846,7 +873,19 @@ impl ReconcilePluginMcpServers {
|
|||||||
active_mcp_specs(self.packages.as_ref(), self.validator.as_ref(), ®istry).await?;
|
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)
|
persist_invalid_runtime_plugins(self.registry.as_ref(), &mut registry, invalid_plugins)
|
||||||
.await;
|
.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ use std::fs;
|
|||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex, MutexGuard};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@ -386,6 +386,8 @@ trait ExternalMcpServerBridge: Send + Sync {
|
|||||||
) -> Result<Box<dyn ExternalMcpServerHandle>, PluginMcpError>;
|
) -> Result<Box<dyn ExternalMcpServerHandle>, PluginMcpError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExternalMcpChildren = HashMap<String, Box<dyn ExternalMcpServerHandle>>;
|
||||||
|
|
||||||
struct ProcessMcpServerHandle {
|
struct ProcessMcpServerHandle {
|
||||||
child: Child,
|
child: Child,
|
||||||
}
|
}
|
||||||
@ -433,7 +435,7 @@ impl ExternalMcpServerBridge for StdioExternalMcpServerBridge {
|
|||||||
/// External process supervisor for plugin MCP servers.
|
/// External process supervisor for plugin MCP servers.
|
||||||
pub struct ExternalMcpPluginSupervisor {
|
pub struct ExternalMcpPluginSupervisor {
|
||||||
bridge: Arc<dyn ExternalMcpServerBridge>,
|
bridge: Arc<dyn ExternalMcpServerBridge>,
|
||||||
children: Mutex<HashMap<String, Box<dyn ExternalMcpServerHandle>>>,
|
children: Mutex<ExternalMcpChildren>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExternalMcpPluginSupervisor {
|
impl ExternalMcpPluginSupervisor {
|
||||||
@ -453,6 +455,12 @@ impl ExternalMcpPluginSupervisor {
|
|||||||
children: Mutex::new(HashMap::new()),
|
children: Mutex::new(HashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn children(&self) -> Result<MutexGuard<'_, ExternalMcpChildren>, PluginMcpError> {
|
||||||
|
self.children
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| PluginMcpError::Process("plugin mcp supervisor mutex poisoned".to_owned()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ExternalMcpPluginSupervisor {
|
impl Default for ExternalMcpPluginSupervisor {
|
||||||
@ -469,10 +477,7 @@ impl PluginMcpSupervisor for ExternalMcpPluginSupervisor {
|
|||||||
) -> Result<PluginMcpStatusSet, PluginMcpError> {
|
) -> Result<PluginMcpStatusSet, PluginMcpError> {
|
||||||
let desired: HashSet<String> = active_servers.iter().map(|s| s.identity.clone()).collect();
|
let desired: HashSet<String> = active_servers.iter().map(|s| s.identity.clone()).collect();
|
||||||
let to_stop = {
|
let to_stop = {
|
||||||
let children = self
|
let children = self.children()?;
|
||||||
.children
|
|
||||||
.lock()
|
|
||||||
.expect("plugin mcp supervisor poisoned");
|
|
||||||
children
|
children
|
||||||
.keys()
|
.keys()
|
||||||
.filter(|id| !desired.contains(*id))
|
.filter(|id| !desired.contains(*id))
|
||||||
@ -480,22 +485,14 @@ impl PluginMcpSupervisor for ExternalMcpPluginSupervisor {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
};
|
};
|
||||||
for id in to_stop {
|
for id in to_stop {
|
||||||
let child = self
|
let child = self.children()?.remove(&id);
|
||||||
.children
|
|
||||||
.lock()
|
|
||||||
.expect("plugin mcp supervisor poisoned")
|
|
||||||
.remove(&id);
|
|
||||||
if let Some(mut child) = child {
|
if let Some(mut child) = child {
|
||||||
let _ = child.stop().await;
|
let _ = child.stop().await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut statuses = Vec::new();
|
let mut statuses = Vec::new();
|
||||||
for spec in active_servers {
|
for spec in active_servers {
|
||||||
let already = self
|
let already = self.children()?.contains_key(&spec.identity);
|
||||||
.children
|
|
||||||
.lock()
|
|
||||||
.expect("plugin mcp supervisor poisoned")
|
|
||||||
.contains_key(&spec.identity);
|
|
||||||
if already {
|
if already {
|
||||||
statuses.push(PluginMcpStatus {
|
statuses.push(PluginMcpStatus {
|
||||||
identity: spec.identity,
|
identity: spec.identity,
|
||||||
@ -506,10 +503,12 @@ impl PluginMcpSupervisor for ExternalMcpPluginSupervisor {
|
|||||||
}
|
}
|
||||||
match self.bridge.start(&spec).await {
|
match self.bridge.start(&spec).await {
|
||||||
Ok(handle) => {
|
Ok(handle) => {
|
||||||
self.children
|
match self.children() {
|
||||||
.lock()
|
Ok(mut children) => {
|
||||||
.expect("plugin mcp supervisor poisoned")
|
children.insert(spec.identity.clone(), handle);
|
||||||
.insert(spec.identity.clone(), handle);
|
}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
statuses.push(PluginMcpStatus {
|
statuses.push(PluginMcpStatus {
|
||||||
identity: spec.identity,
|
identity: spec.identity,
|
||||||
running: true,
|
running: true,
|
||||||
@ -529,10 +528,7 @@ impl PluginMcpSupervisor for ExternalMcpPluginSupervisor {
|
|||||||
async fn stop_plugin(&self, plugin_id: &PluginId) -> Result<(), PluginMcpError> {
|
async fn stop_plugin(&self, plugin_id: &PluginId) -> Result<(), PluginMcpError> {
|
||||||
let prefix = format!("plugin:{}:", plugin_id.as_str());
|
let prefix = format!("plugin:{}:", plugin_id.as_str());
|
||||||
let ids = {
|
let ids = {
|
||||||
let children = self
|
let children = self.children()?;
|
||||||
.children
|
|
||||||
.lock()
|
|
||||||
.expect("plugin mcp supervisor poisoned");
|
|
||||||
children
|
children
|
||||||
.keys()
|
.keys()
|
||||||
.filter(|id| id.starts_with(&prefix))
|
.filter(|id| id.starts_with(&prefix))
|
||||||
@ -540,11 +536,7 @@ impl PluginMcpSupervisor for ExternalMcpPluginSupervisor {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
};
|
};
|
||||||
for id in ids {
|
for id in ids {
|
||||||
let child = self
|
let child = self.children()?.remove(&id);
|
||||||
.children
|
|
||||||
.lock()
|
|
||||||
.expect("plugin mcp supervisor poisoned")
|
|
||||||
.remove(&id);
|
|
||||||
if let Some(mut child) = child {
|
if let Some(mut child) = child {
|
||||||
child.stop().await?;
|
child.stop().await?;
|
||||||
}
|
}
|
||||||
@ -751,4 +743,20 @@ mod tests {
|
|||||||
assert_eq!(statuses.servers[0].identity, other.identity);
|
assert_eq!(statuses.servers[0].identity, other.identity);
|
||||||
assert_eq!(bridge.started.lock().unwrap().len(), 2);
|
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())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
76
frontend/src/app/RootErrorBoundary.test.tsx
Normal file
76
frontend/src/app/RootErrorBoundary.test.tsx
Normal file
@ -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(
|
||||||
|
<RootErrorBoundary>
|
||||||
|
<ThrowingView />
|
||||||
|
</RootErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<RootErrorBoundary>
|
||||||
|
<ThrowingView />
|
||||||
|
</RootErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
102
frontend/src/app/RootErrorBoundary.tsx
Normal file
102
frontend/src/app/RootErrorBoundary.tsx
Normal file
@ -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 (
|
||||||
|
<main className="flex h-full items-center justify-center bg-canvas p-6 text-content">
|
||||||
|
<section
|
||||||
|
role="alert"
|
||||||
|
aria-labelledby="root-error-title"
|
||||||
|
className="flex w-full max-w-2xl flex-col gap-4 rounded-lg border border-danger/50 bg-surface p-5 shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-xs font-medium uppercase text-danger">
|
||||||
|
Interface interrompue
|
||||||
|
</p>
|
||||||
|
<h1 id="root-error-title" className="text-lg font-semibold">
|
||||||
|
IdeA a rencontré une erreur d'affichage.
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
L'application reste ouverte. Rechargez la fenêtre après avoir copié
|
||||||
|
le diagnostic si le problème doit être investigué.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre className="max-h-72 overflow-auto rounded-md border border-border bg-canvas p-3 font-mono text-xs leading-relaxed text-muted">
|
||||||
|
{details || message}
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-on-primary hover:bg-primary-hover"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
Recharger
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__IDEA_GLOBAL_ERROR_LOGGING__?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import { App } from "./App";
|
|||||||
import { ViewWindow, parseViewWindowParams } from "./ViewWindow";
|
import { ViewWindow, parseViewWindowParams } from "./ViewWindow";
|
||||||
import { WebApp } from "@/features/web";
|
import { WebApp } from "@/features/web";
|
||||||
import { DIProvider, resolveTransport } from "./di";
|
import { DIProvider, resolveTransport } from "./di";
|
||||||
|
import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary";
|
||||||
import "@/shared/styles/theme.css";
|
import "@/shared/styles/theme.css";
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
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.
|
// the full `App` (or a detached view window). Detached windows are desktop-only.
|
||||||
const isWeb = resolveTransport() === "http";
|
const isWeb = resolveTransport() === "http";
|
||||||
|
|
||||||
|
installGlobalErrorLogging();
|
||||||
|
|
||||||
ReactDOM.createRoot(root).render(
|
ReactDOM.createRoot(root).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<DIProvider>
|
<RootErrorBoundary>
|
||||||
{isWeb ? (
|
<DIProvider>
|
||||||
<WebApp />
|
{isWeb ? (
|
||||||
) : viewParams ? (
|
<WebApp />
|
||||||
<ViewWindow panel={viewParams.panel} />
|
) : viewParams ? (
|
||||||
) : (
|
<ViewWindow panel={viewParams.panel} />
|
||||||
<App />
|
) : (
|
||||||
)}
|
<App />
|
||||||
</DIProvider>
|
)}
|
||||||
|
</DIProvider>
|
||||||
|
</RootErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 () => {
|
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
|
// The backend contract (carnet §1.3) filters the catalog to
|
||||||
// `enabled && !pendingUninstall` before the loader ever sees it; the
|
// `enabled && !pendingUninstall` before the loader ever sees it; the
|
||||||
|
|||||||
@ -72,6 +72,32 @@ export interface PluginLoadResult {
|
|||||||
failures: PluginLoadFailure[];
|
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 {
|
function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule {
|
||||||
return (
|
return (
|
||||||
typeof mod === "object" &&
|
typeof mod === "object" &&
|
||||||
@ -170,6 +196,7 @@ async function disposeAll(disposables: Disposable[], activation?: void | PluginA
|
|||||||
async function loadOne(
|
async function loadOne(
|
||||||
entry: PluginRuntimePlugin,
|
entry: PluginRuntimePlugin,
|
||||||
gateways: PluginGatewaySet,
|
gateways: PluginGatewaySet,
|
||||||
|
options: Required<PluginLoadOptions>,
|
||||||
): Promise<{ plugin: LoadedPlugin } | { failure: PluginLoadFailure }> {
|
): Promise<{ plugin: LoadedPlugin } | { failure: PluginLoadFailure }> {
|
||||||
const entryObject = objectOrEmpty(entry);
|
const entryObject = objectOrEmpty(entry);
|
||||||
const pluginId = safePluginId(entry);
|
const pluginId = safePluginId(entry);
|
||||||
@ -186,7 +213,13 @@ async function loadOne(
|
|||||||
throw new Error("missing plugin bundle URL");
|
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) {
|
if (!mod) {
|
||||||
return {
|
return {
|
||||||
failure: {
|
failure: {
|
||||||
@ -215,7 +248,11 @@ async function loadOne(
|
|||||||
...gateways,
|
...gateways,
|
||||||
};
|
};
|
||||||
|
|
||||||
activation = await mod.activate(ctx);
|
activation = await withTimeout(
|
||||||
|
Promise.resolve(mod.activate(ctx)),
|
||||||
|
options.timeoutMs,
|
||||||
|
"activating plugin",
|
||||||
|
);
|
||||||
|
|
||||||
const plugin: LoadedPlugin = {
|
const plugin: LoadedPlugin = {
|
||||||
pluginId,
|
pluginId,
|
||||||
@ -251,12 +288,18 @@ async function loadOne(
|
|||||||
export async function loadPlugins(
|
export async function loadPlugins(
|
||||||
catalogPlugins: PluginRuntimePlugin[],
|
catalogPlugins: PluginRuntimePlugin[],
|
||||||
gateways: PluginGatewaySet,
|
gateways: PluginGatewaySet,
|
||||||
|
options: PluginLoadOptions = {},
|
||||||
): Promise<PluginLoadResult> {
|
): Promise<PluginLoadResult> {
|
||||||
const registry = new PluginRuntimeRegistry();
|
const registry = new PluginRuntimeRegistry();
|
||||||
const failures: PluginLoadFailure[] = [];
|
const failures: PluginLoadFailure[] = [];
|
||||||
|
const resolvedOptions: Required<PluginLoadOptions> = {
|
||||||
|
timeoutMs: options.timeoutMs ?? DEFAULT_PLUGIN_LOAD_TIMEOUT_MS,
|
||||||
|
};
|
||||||
|
|
||||||
const entries = Array.isArray(catalogPlugins) ? catalogPlugins : [];
|
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) {
|
for (const result of results) {
|
||||||
if ("failure" in result) failures.push(result.failure);
|
if ("failure" in result) failures.push(result.failure);
|
||||||
else registry.add(result.plugin);
|
else registry.add(result.plugin);
|
||||||
|
|||||||
Reference in New Issue
Block a user