fix(core): wrap fire-and-forget spawn_blocking calls with panic dump … (#810)

* fix(core): wrap fire-and-forget spawn_blocking calls with panic dump protection

* test(utils): cover supervised blocking panic handling

---------

Co-authored-by: Hunter Bown <hmbown@gmail.com>
This commit is contained in:
Gerry Qi
2026-05-06 17:32:37 +08:00
committed by GitHub
parent d5f4d89352
commit 7d798cfa66
3 changed files with 57 additions and 2 deletions
+1 -1
View File
@@ -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);
});
}
+1 -1
View File
@@ -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);
+55
View File
@@ -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<F>(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::<String>() {
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