diff --git a/crates-tauri/yaak-app-client/src/plugins_ext.rs b/crates-tauri/yaak-app-client/src/plugins_ext.rs index 714d7948..3fcc8fe2 100644 --- a/crates-tauri/yaak-app-client/src/plugins_ext.rs +++ b/crates-tauri/yaak-app-client/src/plugins_ext.rs @@ -28,7 +28,7 @@ use yaak_plugins::api::{ PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates, search_plugins, }; -use yaak_plugins::events::PluginContext; +use yaak_plugins::events::{Color, PluginContext, ShowToastRequest}; use yaak_plugins::install::{delete_and_uninstall, download_and_install}; use yaak_plugins::manager::PluginManager; use yaak_plugins::plugin_meta::get_plugin_meta; @@ -315,6 +315,30 @@ pub fn init() -> TauriPlugin { .await .expect("Failed to start plugin runtime"); + // Surface unexpected runtime crashes to the user + let mut crash_rx = manager.runtime_crash_rx(); + let app_handle_crash = app_handle_clone.clone(); + tauri::async_runtime::spawn(async move { + if crash_rx.wait_for(|status| status.is_some()).await.is_ok() { + let status = crash_rx.borrow().clone().unwrap_or_default(); + // The crash may happen during startup, before any window or + // frontend listener exists — wait so the toast isn't lost + while app_handle_crash.webview_windows().is_empty() { + tokio::time::sleep(Duration::from_millis(500)).await; + } + tokio::time::sleep(Duration::from_secs(3)).await; + let _ = app_handle_crash.emit( + "show_toast", + ShowToastRequest { + message: format!("Plugin runtime crashed ({status})"), + color: Some(Color::Danger), + icon: None, + timeout: None, + }, + ); + } + }); + app_handle_clone.manage(manager); }); diff --git a/crates/yaak-plugins/src/manager.rs b/crates/yaak-plugins/src/manager.rs index 584952f2..2616cbd6 100644 --- a/crates/yaak-plugins/src/manager.rs +++ b/crates/yaak-plugins/src/manager.rs @@ -52,6 +52,9 @@ pub struct PluginManager { dev_mode: bool, /// Errors from plugin initialization, retrievable once via `take_init_errors`. init_errors: Arc>>, + /// Set to the exit status if the runtime dies unexpectedly, observable + /// via `runtime_crash_rx`. + runtime_crash_rx: tokio::sync::watch::Receiver>, } /// Callback for plugin initialization events (e.g., toast notifications) @@ -83,6 +86,12 @@ impl PluginManager { let (client_disconnect_tx, mut client_disconnect_rx) = mpsc::channel(128); let (client_connect_tx, mut client_connect_rx) = tokio::sync::watch::channel(false); + // Set to the exit status if the runtime dies unexpectedly. The app is + // still usable without the plugin runtime (just missing features), so + // this unblocks startup and is surfaced to the user rather than + // taking the app down. + let (unexpected_exit_tx, unexpected_exit_rx) = + tokio::sync::watch::channel::>(None); let ws_service = PluginRuntimeServerWebsocket::new(events_tx, client_disconnect_tx, client_connect_tx); @@ -96,6 +105,7 @@ impl PluginManager { installed_plugin_dir, dev_mode, init_errors: Default::default(), + runtime_crash_rx: unexpected_exit_rx.clone(), }; // Forward events to subscribers @@ -132,16 +142,22 @@ impl PluginManager { let listener = TcpListener::bind(listen_addr).await.expect("Failed to bind TCP listener"); let addr = listener.local_addr().expect("Failed to get local address"); - // 1. Wait for Node.js runtime to connect + // 1. Wait for the Node.js runtime to connect, or for it to die trying + let mut init_dead_rx = unexpected_exit_rx.clone(); let init_plugins_task = tokio::spawn(async move { - match client_connect_rx.changed().await { - Ok(_) => { - info!("Plugin runtime client connected!"); - // Note: initialize_all_plugins is now called separately by the app - // after setting up the plugin list - } - Err(e) => { - warn!("Failed to receive from client connection rx {e:?}"); + tokio::select! { + result = client_connect_rx.changed() => match result { + Ok(_) => { + info!("Plugin runtime client connected!"); + // Note: initialize_all_plugins is now called separately by the app + // after setting up the plugin list + } + Err(e) => { + warn!("Failed to receive from client connection rx {e:?}"); + } + }, + _ = init_dead_rx.wait_for(|status| status.is_some()) => { + warn!("Plugin runtime exited before connecting; continuing without plugins"); } } }); @@ -159,11 +175,17 @@ impl PluginManager { addr, &kill_server_rx, killed_tx, + unexpected_exit_tx, ) .await?; info!("Waiting for plugins to initialize"); init_plugins_task.await.map_err(|e| PluginErr(e.to_string()))?; + if unexpected_exit_rx.borrow().is_some() { + warn!("Skipping plugin initialization because the runtime is not running"); + return Ok(plugin_manager); + } + let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?; let db = query_manager.connect(); for dir in &bundled_dirs { @@ -201,6 +223,12 @@ impl PluginManager { std::mem::take(&mut *self.init_errors.lock().await) } + /// A receiver that is set to the exit status if the plugin runtime dies + /// unexpectedly. + pub fn runtime_crash_rx(&self) -> tokio::sync::watch::Receiver> { + self.runtime_crash_rx.clone() + } + /// Get the vendored plugin directory path (resolves dev mode path if applicable) pub fn get_plugins_dir(&self) -> PathBuf { if self.dev_mode { diff --git a/crates/yaak-plugins/src/nodejs.rs b/crates/yaak-plugins/src/nodejs.rs index 314abbb5..882199b8 100644 --- a/crates/yaak-plugins/src/nodejs.rs +++ b/crates/yaak-plugins/src/nodejs.rs @@ -1,5 +1,5 @@ use crate::error::Result; -use log::{info, warn}; +use log::{error, info, warn}; use std::net::SocketAddr; use std::path::Path; use std::process::Stdio; @@ -15,12 +15,16 @@ use yaak_common::command::new_xplatform_command; /// * `plugin_runtime_main` - Path to the plugin runtime index.cjs /// * `addr` - Socket address for the plugin runtime to connect to /// * `kill_rx` - Channel to signal shutdown +/// * `killed_tx` - Notified once the runtime is killed after a shutdown signal +/// * `unexpected_exit_tx` - Set to the exit status if the runtime exits +/// without being asked to pub async fn start_nodejs_plugin_runtime( node_bin_path: &Path, plugin_runtime_main: &Path, addr: SocketAddr, kill_rx: &Receiver, killed_tx: oneshot::Sender<()>, + unexpected_exit_tx: tokio::sync::watch::Sender>, ) -> Result<()> { // HACK: Remove UNC prefix for Windows paths to pass to sidecar let plugin_runtime_main_str = @@ -65,18 +69,29 @@ pub async fn start_nodejs_plugin_runtime( }); } - // Handle kill signal + // Wait for either an explicit kill signal or the child exiting on its own. + // An unexpected exit is reported to the caller, which decides whether to + // abort startup or surface a user-facing error. let mut kill_rx = kill_rx.clone(); tokio::spawn(async move { - if kill_rx.wait_for(|b| *b == true).await.is_err() { - warn!("Kill channel closed before explicit shutdown; terminating plugin runtime"); + tokio::select! { + status = child.wait() => { + let status = status.map(|s| s.to_string()).unwrap_or_else(|e| e.to_string()); + error!("Plugin runtime exited unexpectedly ({status})"); + let _ = unexpected_exit_tx.send(Some(status)); + } + closed = async { kill_rx.wait_for(|b| *b == true).await.is_err() } => { + if closed { + warn!("Kill channel closed before explicit shutdown; terminating plugin runtime"); + } + info!("Killing plugin runtime"); + if let Err(e) = child.kill().await { + warn!("Failed to kill plugin runtime: {e}"); + } + info!("Killed plugin runtime"); + let _ = killed_tx.send(()); + } } - info!("Killing plugin runtime"); - if let Err(e) = child.kill().await { - warn!("Failed to kill plugin runtime: {e}"); - } - info!("Killed plugin runtime"); - let _ = killed_tx.send(()); }); Ok(())