From 1dda103bf0b66ee2e4328add79797ecab8960443 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:29:45 +0800 Subject: [PATCH] fix(app): close a project window without blocking the UI thread (#1610) close_project was a synchronous command, so it ran on the UI thread and dropped the project there: stopping its file watcher and index worker waits for the batch of file events they are applying, many while a build or rust-analyzer writes output. The command now runs off the main thread, and the project is dropped on the project shutdown thread, which a project opened next still waits for (the index database is released first). The window Destroyed path gets the same. --- crates/ide-host/src/lib.rs | 44 +++++++++++++++++++++++++++++++++++ src-tauri/src/commands/app.rs | 3 ++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 1f893f47b..d1f395369 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -245,6 +245,7 @@ impl Host { project.kotlin_bridge.iter().cloned().collect(), Some(project.lsp.clone()), Some((self.debugger.clone(), window.to_owned())), + Some(project), )) } @@ -257,6 +258,7 @@ impl Host { bridges: Vec>, lsp: Option>, debug: Option<(Arc, String)>, + retired: Option>, ) -> std::thread::JoinHandle<()> { for bridge in &bridges { bridge.stop(); @@ -266,6 +268,10 @@ impl Host { } let runtime = self.runtime.clone(); std::thread::spawn(move || { + // Dropping the project stops its file watcher and index worker, which first finish the + // batch they are applying (many file events while a build writes its output), so it + // isn't done on the closing caller's thread (the UI thread for a window close). + let retired = retired.map(|project| std::thread::spawn(move || drop(project))); runtime.block_on(async move { for bridge in bridges { bridge.shutdown().await; @@ -280,6 +286,9 @@ impl Host { debug.stop_key(&key).await; } }); + if let Some(retired) = retired { + let _ = retired.join(); + } }) } @@ -319,6 +328,7 @@ impl Host { projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(), None, None, + None, )); let deadline = std::time::Instant::now() + EXIT_SHUTDOWN_WAIT; while pending.iter().any(|handle| !handle.is_finished()) { @@ -422,3 +432,37 @@ impl Host { self.projects.write().insert(window.to_owned(), project); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn detached_close_releases_the_project_before_a_reopen() { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("project"); + std::fs::create_dir(&root).unwrap(); + std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap(); + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("local"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(|_| {})), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + let project = Arc::downgrade(&host.open_project("main", &root).unwrap()); + host.close_project_detached("main"); + assert!(host.project("main").is_err()); + // The project (its file watcher and index database) is dropped off the closing thread, but + // before opening a project waits out the close. + host.wait_detached_closes(); + assert!(project.upgrade().is_none(), "the closed project is dropped"); + host.open_project("main", &root).unwrap(); + host.close_project("main"); + } +} diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 7c578337a..dcd00e7cf 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -129,7 +129,8 @@ pub fn project_rename(window: Window, state: State<'_, AppState>, name: String) Ok(project_info(&project)) } -#[tauri::command] +// Not on the main thread: closing stops the project's language servers, runs and file watcher. +#[tauri::command(async)] #[specta::specta] pub fn close_project(window: Window, state: State<'_, AppState>) { crate::windows::close_auxiliary_windows(window.app_handle(), window.label());