fix(properties): avoid Windows WebView deadlock (#37)

- Create Properties WebView windows from an async Tauri command
- Serialize open, close, and removal lifecycle operations
- Refs #37
This commit is contained in:
NimBold
2026-08-28 02:45:15 +03:30
parent e4a698eb7f
commit 7477a26378
+34 -7
View File
@@ -25,6 +25,7 @@ const PROPERTIES_SESSION_HISTORY_EXHAUSTED: &str =
#[derive(Default)]
pub struct PropertiesWindowRegistry {
state: Mutex<RegistryState>,
window_creation: tokio::sync::Mutex<()>,
}
#[derive(Default)]
@@ -63,6 +64,10 @@ struct PropertiesWindowActionEvent {
}
impl PropertiesWindowRegistry {
async fn lock_window_creation(&self) -> tokio::sync::MutexGuard<'_, ()> {
self.window_creation.lock().await
}
pub(crate) fn remember_size(
&self,
window_label: &str,
@@ -394,13 +399,18 @@ fn validate_properties_request_id(request_id: u64) -> Result<(), String> {
}
#[tauri::command]
pub fn open_download_properties_window(
pub async fn open_download_properties_window(
app: tauri::AppHandle,
caller: tauri::WebviewWindow,
db: tauri::State<'_, crate::db::DbState>,
registry: tauri::State<'_, PropertiesWindowRegistry>,
id: String,
) -> Result<String, String> {
// WebviewWindowBuilder::build can deadlock on Windows when it runs in a
// synchronous command or event handler because WebView2 initialization
// needs the native event loop to keep pumping. This command is async so
// Tauri executes the blocking construction away from the renderer/native
// command callback that initiated it.
if caller.label() != MAIN_WINDOW_LABEL {
return Err("Only the main window can open Properties windows".to_string());
}
@@ -409,6 +419,11 @@ pub fn open_download_properties_window(
return Err("Download no longer exists".to_string());
}
// Async command invocations can overlap before Tauri registers a newly
// created native window. Serialize the lookup/build/cleanup transaction
// so a duplicate request cannot remove the registry entry of the request
// that successfully created the window.
let _window_creation_guard = registry.lock_window_creation().await;
let label = registry.allocate(&id)?;
if let Some(window) = app.get_webview_window(&label) {
// Visibility belongs to the native window owner, not to the renderer
@@ -445,10 +460,9 @@ pub fn open_download_properties_window(
let builder = builder.decorations(false);
let build_result = builder.build();
if let Err(error) = build_result {
// Two rapid main-window requests can race between the native lookup
// above and builder creation. If the first request won, retain the
// registry entry and focus its window instead of treating the second
// request as a failed open.
// The native builder can report an error after registering a window.
// Prefer that registered native owner over discarding its registry
// entry and leaving the child inaccessible.
if let Some(window) = app.get_webview_window(&label) {
let _ = window.unminimize();
let _ = window.show();
@@ -590,12 +604,13 @@ pub fn validate_properties_window_request(
}
#[tauri::command]
pub fn close_download_properties_window(
pub async fn close_download_properties_window(
caller: tauri::WebviewWindow,
app: tauri::AppHandle,
registry: tauri::State<'_, PropertiesWindowRegistry>,
id: String,
) -> Result<(), String> {
let _window_creation_guard = registry.lock_window_creation().await;
let label = caller.label();
let registered_id = if label == MAIN_WINDOW_LABEL {
registry.window_for_download(&id)?.map(|_| id.clone())
@@ -621,7 +636,7 @@ pub fn close_download_properties_window(
}
#[tauri::command]
pub fn properties_window_registry_remove_for_download(
pub async fn properties_window_registry_remove_for_download(
caller: tauri::WebviewWindow,
app: tauri::AppHandle,
registry: tauri::State<'_, PropertiesWindowRegistry>,
@@ -630,6 +645,7 @@ pub fn properties_window_registry_remove_for_download(
if caller.label() != MAIN_WINDOW_LABEL {
return Err("Only the main window can remove a Properties window".to_string());
}
let _window_creation_guard = registry.lock_window_creation().await;
if let Some(label) = registry.remove_download(&id)? {
if let Some(window) = app.get_webview_window(&label) {
// This command is used after the download has already been
@@ -672,6 +688,17 @@ mod tests {
assert_ne!(registry.allocate("download-a").unwrap(), first);
}
#[tokio::test]
async fn window_creation_lock_is_exclusive() {
let registry = PropertiesWindowRegistry::default();
let guard = registry.lock_window_creation().await;
assert!(registry.window_creation.try_lock().is_err());
drop(guard);
assert!(registry.window_creation.try_lock().is_ok());
}
#[test]
fn remembered_size_uses_logical_units_and_survives_window_cleanup() {
let registry = PropertiesWindowRegistry::default();