Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ tokio-test = "0.4"
criterion = { version = "0.5", features = ["html_reports"] }

[profile.release]
panic = "abort"
panic = "unwind"
codegen-units = 1
lto = true
opt-level = 3 # Use speed optimization, not size - critical for SurrealDB performance
Expand Down
1 change: 1 addition & 0 deletions packages/desktop-app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
futures = "0.3"
axum = { version = "0.7", features = ["default"] }
tokio-stream = "0.1"
tokio-util = "0.7"
async-stream = "0.3"
tower-http = { version = "0.6", features = ["cors"] }
surrealdb = { workspace = true }
Expand Down
14 changes: 10 additions & 4 deletions packages/desktop-app/src-tauri/src/commands/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,19 +168,25 @@ async fn init_services(app: &AppHandle, db_path: PathBuf) -> Result<(), String>
eprintln!("✅ [init_services] All services registered with Tauri");
tracing::info!("✅ [init_services] All services registered with Tauri");

// Retrieve the shutdown token from Tauri state for background task coordination
let shutdown_token: tauri::State<crate::ShutdownToken> = app.state();

// Initialize MCP server now that NodeService is available
// MCP will use the same NodeService as Tauri commands
if let Err(e) = crate::initialize_mcp_server(app.clone()) {
if let Err(e) = crate::initialize_mcp_server(app.clone(), shutdown_token.child_token()) {
tracing::error!("❌ Failed to initialize MCP server: {}", e);
// Don't fail database init if MCP fails - MCP is optional
}

// Initialize domain event forwarding with client filtering (#665)
// Events that originated from this Tauri client are filtered out to prevent feedback loops
let client_id = "tauri-main".to_string();
if let Err(e) =
crate::initialize_domain_event_forwarder(app.clone(), node_service_arc.clone(), client_id)
{
if let Err(e) = crate::initialize_domain_event_forwarder(
app.clone(),
node_service_arc.clone(),
client_id,
shutdown_token.child_token(),
) {
tracing::error!("❌ Failed to initialize domain event forwarder: {}", e);
// Don't fail database init if event forwarding fails - it's not critical
}
Expand Down
108 changes: 90 additions & 18 deletions packages/desktop-app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,14 @@ mod tests;
/// achieving real-time sync through event-driven architecture.
///
/// Events that originated from this Tauri client are filtered out (prevents feedback loop).
///
/// The `cancel_token` is used for graceful shutdown - when cancelled, the forwarder
/// will stop its event loop and exit cleanly before the Tokio runtime drops.
pub fn initialize_domain_event_forwarder(
app: tauri::AppHandle,
node_service: std::sync::Arc<nodespace_core::NodeService>,
client_id: String,
cancel_token: tokio_util::sync::CancellationToken,
) -> anyhow::Result<()> {
use crate::services::DomainEventForwarder;
use futures::FutureExt;
Expand All @@ -53,7 +57,7 @@ pub fn initialize_domain_event_forwarder(
tauri::async_runtime::spawn(async move {
let result = std::panic::AssertUnwindSafe(async {
let forwarder = DomainEventForwarder::new(node_service, app, client_id);
forwarder.run().await
forwarder.run(cancel_token).await
})
.catch_unwind()
.await;
Expand Down Expand Up @@ -84,8 +88,14 @@ pub fn initialize_domain_event_forwarder(
/// and NodeEmbeddingService and spawns the MCP server task with them,
/// ensuring MCP and Tauri commands operate on the same database.
///
/// The `cancel_token` is used for graceful shutdown - when cancelled, the MCP
/// server task will be aborted before the Tokio runtime drops.
///
/// As of Issue #715, uses McpServerService from nodespace-core for managed lifecycle.
pub fn initialize_mcp_server(app: tauri::AppHandle) -> anyhow::Result<()> {
pub fn initialize_mcp_server(
app: tauri::AppHandle,
cancel_token: tokio_util::sync::CancellationToken,
) -> anyhow::Result<()> {
use crate::commands::embeddings::EmbeddingState;
use futures::FutureExt;
use nodespace_core::NodeService;
Expand Down Expand Up @@ -119,40 +129,78 @@ pub fn initialize_mcp_server(app: tauri::AppHandle) -> anyhow::Result<()> {

// Spawn MCP server task with Tauri event emissions
// Uses panic protection to prevent silent background task failures
// Monitors cancel_token for graceful shutdown before runtime drops
tauri::async_runtime::spawn(async move {
// Use FutureExt::catch_unwind for proper async panic catching
// This avoids the deadlock risk of using block_on inside an async task
// AssertUnwindSafe is needed because services contain non-UnwindSafe types
let result = std::panic::AssertUnwindSafe(mcp_service.start_with_callback(callback))
.catch_unwind()
.await;
let result = std::panic::AssertUnwindSafe(async {
tokio::select! {
res = mcp_service.start_with_callback(callback) => res,
_ = cancel_token.cancelled() => {
tracing::info!("MCP server received shutdown signal");
Ok(())
}
}
})
.catch_unwind()
.await;

match result {
Ok(Ok(_)) => {
tracing::info!("✅ MCP server exited normally");
}
Ok(Err(e)) => {
tracing::error!("❌ MCP server error: {}", e);
// TODO: Consider emitting Tauri event to notify UI of MCP failure
}
Err(panic_info) => {
tracing::error!("💥 MCP server panicked: {:?}", panic_info);
// TODO: Consider attempting automatic restart or notifying user
}
}
});

Ok(())
}

/// Shared shutdown token for graceful background task termination.
///
/// Managed as Tauri state so it can be accessed from both the setup phase
/// (where background tasks are spawned) and the run event handler (where
/// shutdown is triggered). When cancelled, all background tasks (MCP server,
/// domain event forwarder) exit their loops before the Tokio runtime drops.
#[derive(Clone)]
pub struct ShutdownToken(tokio_util::sync::CancellationToken);

impl ShutdownToken {
fn new() -> Self {
Self(tokio_util::sync::CancellationToken::new())
}

/// Create a child token for a background task.
/// Cancelling the parent automatically cancels all children.
pub fn child_token(&self) -> tokio_util::sync::CancellationToken {
self.0.child_token()
}

/// Signal all background tasks to shut down.
/// Idempotent - safe to call multiple times.
pub fn cancel(&self) {
self.0.cancel();
}
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
use tauri::{menu::*, Emitter, Manager, RunEvent};

// Create shutdown token for coordinating graceful background task termination
let shutdown_token = ShutdownToken::new();
let shutdown_token_for_setup = shutdown_token.clone();

let app = tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
.setup(move |app| {
// Create menu items
let toggle_sidebar = MenuItemBuilder::new("Toggle Sidebar")
.id("toggle_sidebar")
Expand Down Expand Up @@ -209,6 +257,10 @@ pub fn run() {
// Set the menu
app.set_menu(menu)?;

// Register shutdown token as managed state so commands/db.rs can access it
// when spawning background tasks (MCP server, domain event forwarder)
app.manage(shutdown_token_for_setup);

// Note: MCP server initialization is deferred until database is initialized
// See commands/db.rs::init_services() which calls initialize_mcp_server()
// after NodeService is available in Tauri state
Expand Down Expand Up @@ -308,26 +360,34 @@ pub fn run() {
.expect("error while building tauri application");

// Run with event handler for graceful shutdown
// This allows proper cleanup of Metal/GPU resources before exit
// This allows proper cleanup of Metal/GPU resources and background tasks before exit
//
// Note: On macOS, RunEvent::ExitRequested may not fire reliably (Tauri issue #9198)
// We handle cleanup in multiple places to ensure GPU resources are released:
// We handle cleanup in multiple places to ensure resources are released:
// 1. WindowEvent::CloseRequested - when user clicks X or Cmd+Q
// 2. RunEvent::ExitRequested - when app is about to exit
// 3. RunEvent::Exit - final cleanup before process termination
app.run(|app_handle, event| {
//
// The shutdown_token is cancelled to signal background tasks (MCP server,
// domain event forwarder) to exit their loops before the Tokio runtime drops.
let shutdown_token_for_events = shutdown_token.clone();
app.run(move |app_handle, event| {
match event {
RunEvent::WindowEvent {
label,
event: tauri::WindowEvent::CloseRequested { .. },
..
} => {
// Window close requested - release GPU resources before the window closes
// Window close requested - signal background tasks and release GPU resources
// This is the most reliable place to do cleanup on macOS
tracing::info!(
"Window '{}' close requested, releasing GPU context...",
"Window '{}' close requested, signaling shutdown and releasing GPU context...",
label
);
shutdown_token_for_events.cancel();
// Brief pause to let background tasks exit their loops before
// releasing GPU resources they may still reference
std::thread::sleep(std::time::Duration::from_millis(50));
release_gpu_resources(app_handle);
}
RunEvent::ExitRequested { code, .. } => {
Expand All @@ -336,33 +396,45 @@ pub fn run() {
"App exit requested (code: {:?}), performing cleanup...",
code
);
shutdown_token_for_events.cancel();
release_gpu_resources(app_handle);
tracing::info!("Cleanup complete, exiting...");
}
RunEvent::Exit => {
// Final exit - last chance to cleanup (may be too late for GPU resources)
tracing::info!("App exiting normally");
// Final exit - ensure shutdown signal is sent (idempotent)
tracing::info!("App exiting, ensuring shutdown signal sent...");
shutdown_token_for_events.cancel();
}
_ => {}
}
});
}

/// Release GPU resources (Metal context) to prevent SIGABRT crash on exit.
/// Release GPU resources (Metal context and backend) to prevent SIGABRT crash on exit.
///
/// This must be called before the app exits to properly clean up Metal/GPU
/// resources. The crash occurs when ggml_metal_rsets_free is called during
/// static destruction (__cxa_finalize_ranges) while resources are still in use.
///
/// Cleanup order is critical:
/// 1. Release LlamaState (context + model) - frees Metal residency sets
/// 2. Release global LLAMA_BACKEND - frees the Metal backend itself
///
/// This prevents __cxa_finalize_ranges from encountering Metal resources during
/// static destruction, which would cause SIGABRT in production builds.
fn release_gpu_resources(app_handle: &tauri::AppHandle) {
use tauri::Manager;

if let Some(embedding_state) =
app_handle.try_state::<crate::commands::embeddings::EmbeddingState>()
{
tracing::info!("Releasing GPU context to prevent Metal crash...");
// Release the LlamaContext which holds Metal residency sets
// This must happen before the global LLAMA_BACKEND static is destroyed
// Step 1: Release the LlamaState (context + model) which holds Metal residency sets
embedding_state.service.nlp_engine().release_gpu_context();
tracing::info!("GPU context released successfully");
}

// Step 2: Release the global llama backend itself
// Must happen AFTER all models/contexts are dropped (step 1)
nodespace_nlp_engine::release_llama_backend();
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ impl DomainEventForwarder {
///
/// Filters out events where source_client_id matches this client's ID
/// to prevent feedback loops from the Tauri frontend receiving its own changes.
pub async fn run(self) -> Result<()> {
///
/// The `cancel_token` allows graceful shutdown - when cancelled, the loop exits
/// cleanly instead of being killed when the Tokio runtime drops.
pub async fn run(self, cancel_token: tokio_util::sync::CancellationToken) -> Result<()> {
info!(
"🔧 Starting domain event forwarding service (client_id: {})",
self.client_id
Expand All @@ -74,22 +77,29 @@ impl DomainEventForwarder {

info!("✅ Event subscription established successfully");

// Listen for events and forward to frontend
// Listen for events and forward to frontend, with graceful shutdown support
loop {
match rx.recv().await {
Ok(event) => {
debug!("Received domain event: {:?}", event);
self.forward_event(&event);
}
Err(broadcast::error::RecvError::Lagged(_)) => {
// Event queue was too long, skip missed events
debug!("Event queue lagged, some events may have been skipped");
tokio::select! {
result = rx.recv() => {
match result {
Ok(event) => {
debug!("Received domain event: {:?}", event);
self.forward_event(&event);
}
Err(broadcast::error::RecvError::Lagged(_)) => {
debug!("Event queue lagged, some events may have been skipped");
}
Err(broadcast::error::RecvError::Closed) => {
error!("Event broadcast channel closed, stopping synchronization service");
self.emit_status("disconnected", Some("channel-closed"));
return Err(anyhow::anyhow!("Event broadcast channel closed"));
}
}
}
Err(broadcast::error::RecvError::Closed) => {
// Broadcast channel closed, service should shut down
error!("Event broadcast channel closed, stopping synchronization service");
self.emit_status("disconnected", Some("channel-closed"));
return Err(anyhow::anyhow!("Event broadcast channel closed"));
_ = cancel_token.cancelled() => {
info!("Domain event forwarder received shutdown signal, exiting gracefully");
self.emit_status("disconnected", Some("shutdown"));
return Ok(());
}
}
}
Expand Down
Loading