Detect and surface plugin runtime crashes (#502)

This commit is contained in:
Gregory Schier
2026-07-09 09:05:34 -07:00
committed by GitHub
parent 2fee5ba413
commit 155260521e
3 changed files with 87 additions and 20 deletions
@@ -28,7 +28,7 @@ use yaak_plugins::api::{
PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates, PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,
search_plugins, 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::install::{delete_and_uninstall, download_and_install};
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_meta::get_plugin_meta; use yaak_plugins::plugin_meta::get_plugin_meta;
@@ -315,6 +315,30 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.await .await
.expect("Failed to start plugin runtime"); .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); app_handle_clone.manage(manager);
}); });
+37 -9
View File
@@ -52,6 +52,9 @@ pub struct PluginManager {
dev_mode: bool, dev_mode: bool,
/// Errors from plugin initialization, retrievable once via `take_init_errors`. /// Errors from plugin initialization, retrievable once via `take_init_errors`.
init_errors: Arc<Mutex<Vec<(String, String)>>>, init_errors: Arc<Mutex<Vec<(String, String)>>>,
/// Set to the exit status if the runtime dies unexpectedly, observable
/// via `runtime_crash_rx`.
runtime_crash_rx: tokio::sync::watch::Receiver<Option<String>>,
} }
/// Callback for plugin initialization events (e.g., toast notifications) /// 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_disconnect_tx, mut client_disconnect_rx) = mpsc::channel(128);
let (client_connect_tx, mut client_connect_rx) = tokio::sync::watch::channel(false); 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::<Option<String>>(None);
let ws_service = let ws_service =
PluginRuntimeServerWebsocket::new(events_tx, client_disconnect_tx, client_connect_tx); PluginRuntimeServerWebsocket::new(events_tx, client_disconnect_tx, client_connect_tx);
@@ -96,6 +105,7 @@ impl PluginManager {
installed_plugin_dir, installed_plugin_dir,
dev_mode, dev_mode,
init_errors: Default::default(), init_errors: Default::default(),
runtime_crash_rx: unexpected_exit_rx.clone(),
}; };
// Forward events to subscribers // Forward events to subscribers
@@ -132,16 +142,22 @@ impl PluginManager {
let listener = TcpListener::bind(listen_addr).await.expect("Failed to bind TCP listener"); let listener = TcpListener::bind(listen_addr).await.expect("Failed to bind TCP listener");
let addr = listener.local_addr().expect("Failed to get local address"); 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 { let init_plugins_task = tokio::spawn(async move {
match client_connect_rx.changed().await { tokio::select! {
Ok(_) => { result = client_connect_rx.changed() => match result {
info!("Plugin runtime client connected!"); Ok(_) => {
// Note: initialize_all_plugins is now called separately by the app info!("Plugin runtime client connected!");
// after setting up the plugin list // 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:?}"); 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, addr,
&kill_server_rx, &kill_server_rx,
killed_tx, killed_tx,
unexpected_exit_tx,
) )
.await?; .await?;
info!("Waiting for plugins to initialize"); info!("Waiting for plugins to initialize");
init_plugins_task.await.map_err(|e| PluginErr(e.to_string()))?; 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 bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?;
let db = query_manager.connect(); let db = query_manager.connect();
for dir in &bundled_dirs { for dir in &bundled_dirs {
@@ -201,6 +223,12 @@ impl PluginManager {
std::mem::take(&mut *self.init_errors.lock().await) 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<Option<String>> {
self.runtime_crash_rx.clone()
}
/// Get the vendored plugin directory path (resolves dev mode path if applicable) /// Get the vendored plugin directory path (resolves dev mode path if applicable)
pub fn get_plugins_dir(&self) -> PathBuf { pub fn get_plugins_dir(&self) -> PathBuf {
if self.dev_mode { if self.dev_mode {
+25 -10
View File
@@ -1,5 +1,5 @@
use crate::error::Result; use crate::error::Result;
use log::{info, warn}; use log::{error, info, warn};
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::Path; use std::path::Path;
use std::process::Stdio; 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 /// * `plugin_runtime_main` - Path to the plugin runtime index.cjs
/// * `addr` - Socket address for the plugin runtime to connect to /// * `addr` - Socket address for the plugin runtime to connect to
/// * `kill_rx` - Channel to signal shutdown /// * `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( pub async fn start_nodejs_plugin_runtime(
node_bin_path: &Path, node_bin_path: &Path,
plugin_runtime_main: &Path, plugin_runtime_main: &Path,
addr: SocketAddr, addr: SocketAddr,
kill_rx: &Receiver<bool>, kill_rx: &Receiver<bool>,
killed_tx: oneshot::Sender<()>, killed_tx: oneshot::Sender<()>,
unexpected_exit_tx: tokio::sync::watch::Sender<Option<String>>,
) -> Result<()> { ) -> Result<()> {
// HACK: Remove UNC prefix for Windows paths to pass to sidecar // HACK: Remove UNC prefix for Windows paths to pass to sidecar
let plugin_runtime_main_str = 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(); let mut kill_rx = kill_rx.clone();
tokio::spawn(async move { tokio::spawn(async move {
if kill_rx.wait_for(|b| *b == true).await.is_err() { tokio::select! {
warn!("Kill channel closed before explicit shutdown; terminating plugin runtime"); 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(()) Ok(())