diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 0e153091..19c7262f 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1013,7 +1013,7 @@ impl Engine { if self.config.snapshots_enabled { let post_workspace = self.session.workspace.clone(); let post_seq = self.turn_counter; - tokio::task::spawn_blocking(move || { + crate::utils::spawn_blocking_supervised("post-turn-snapshot", move || { post_turn_snapshot(&post_workspace, post_seq); }); } diff --git a/crates/tui/src/tui/file_tree.rs b/crates/tui/src/tui/file_tree.rs index 19560c2d..c47b3683 100644 --- a/crates/tui/src/tui/file_tree.rs +++ b/crates/tui/src/tui/file_tree.rs @@ -61,7 +61,7 @@ impl FileTreeState { let loading_cell = Arc::new(Mutex::new(None)); let cell = loading_cell.clone(); let ws = workspace.to_path_buf(); - tokio::task::spawn_blocking(move || { + crate::utils::spawn_blocking_supervised("file-tree-build", move || { let entries = build_file_tree_inner(&ws, &HashSet::new(), None); if let Ok(mut guard) = cell.lock() { *guard = Some(entries); diff --git a/crates/tui/src/utils.rs b/crates/tui/src/utils.rs index 63ef5002..cd80ebd8 100644 --- a/crates/tui/src/utils.rs +++ b/crates/tui/src/utils.rs @@ -274,6 +274,40 @@ fn write_panic_dump_to( Ok(()) } +/// Fire-and-forget `spawn_blocking` with panic dump protection. +/// +/// In contrast to `spawn_supervised` (which wraps `tokio::spawn` for async +/// tasks), this helper wraps `tokio::task::spawn_blocking`. Use it when a +/// CPU-bound or blocking-I/O task must run off the async runtime and its +/// completion is *not* awaited — for example a post-turn disk snapshot or a +/// file-tree build polled later via a shared data structure. If the closure +/// panics, a crash dump is written to `~/.deepseek/crashes/` and the panic +/// is logged at ERROR level rather than being silently swallowed. +#[track_caller] +pub fn spawn_blocking_supervised(name: &'static str, f: F) -> tokio::task::JoinHandle<()> +where + F: FnOnce() + Send + 'static, +{ + let location = std::panic::Location::caller(); + tokio::task::spawn_blocking(move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); + if let Err(panic_info) = result { + let msg = if let Some(s) = panic_info.downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = panic_info.downcast_ref::() { + s.clone() + } else { + "unknown panic".to_string() + }; + tracing::error!( + target: "panic", + "Blocking task '{name}' panicked at {location}: {msg}", + ); + let _ = write_panic_dump(name, location, &msg); + } + }) +} + #[allow(dead_code)] pub fn ensure_dir(path: &Path) -> Result<()> { fs::create_dir_all(path) @@ -579,6 +613,27 @@ mod spawn_supervised_tests { ); } + #[tokio::test] + async fn panicking_blocking_task_does_not_propagate_to_parent() { + let parent_alive = Arc::new(AtomicBool::new(false)); + let parent_alive_clone = parent_alive.clone(); + + let handle = spawn_blocking_supervised("blocking-panic-test-fixture", move || { + parent_alive_clone.store(true, Ordering::SeqCst); + panic!("deliberate panic for spawn_blocking catch-unwind test"); + }); + + let result = handle.await; + assert!( + result.is_ok(), + "spawn_blocking_supervised must convert panic to a normal completion" + ); + assert!( + parent_alive.load(Ordering::SeqCst), + "fixture blocking task must have run before panicking" + ); + } + /// `write_panic_dump_to` writes a properly-formatted crash log into /// the supplied directory. Tested separately from `spawn_supervised` /// because env-mutation redirection of `dirs::home_dir()` doesn't