global-shortcut: the shortcuts mutex is held while a handler runs, so a handler that pumps messages deadlocks the app

Open Beginner friendly
#3,590 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
84/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
rust
Domain
desktop-dev

Research direction

Read plugins/global-shortcut/src/lib.rs around lines 416-425, especially the set_event_handler closure and its shortcuts lock. Run the minimal reproduction that calls is_registered from a shortcut handler. Done means the handler runs without the shortcuts mutex held and callbacks can re-enter the plugin without deadlocking.

Written by the indexing model from the issue text.

Description

plugin: global-shortcut type: bug
Describe the bug

GlobalHotKeyEvent::set_event_handler calls the user's handler from inside an if let whose scrutinee locks shortcuts, and that temporary guard lives until the end of the if let body. The handler therefore runs with the plugin's shortcuts mutex held. std::sync::Mutex is not reentrant, so anything that re-enters this closure from inside a handler blocks forever on a lock its own thread is already holding.

On Windows that is easy to reach from ordinary code: creating a webview pumps messages (webview2_com::wait_with_pump), the pump dispatches the next WM_HOTKEY, and the callback re-enters. The process then freezes permanently — the window never appears, the tray stops answering, and only Task Manager is left.

Two details worth calling out:

  • The second shortcut does not need a handler. The lock is taken by the plugin before any user code runs, so the freeze happens on the way into the second event, never in it. What it does need is to be a second WM_HOTKEY, which is what the pump has to dispatch: another registered shortcut, or the same one pressed again. The key release is a different story — global-hotkey sends that from a spawned polling thread, so it only blocks that thread until the handler returns.
  • Nothing about a handler is special. is_registered(), register() and unregister() take the same lock (lib.rs:237, 113, 188), so calling any of them from inside a handler deadlocks the main thread by itself, with no second event at all — as does anything else the handler does that pumps on the main thread, such as a modal dialog.
Reproduction

Minimal, no window involved, nothing to do with WebView2 -- the plugin's own lock, re-entered through the plugin's own API:

app.global_shortcut().on_shortcut("alt+w", |app, _, _| {
    // Any call back into the plugin lands here: this takes the same `shortcuts`
    // lock that the callback itself is being run under.
    let _ = app.global_shortcut().is_registered("alt+q");
})?;

Press alt+w and the main thread is gone. register, unregister, unregister_all and a second on_shortcut take the same lock, so they all do the same thing.

The one that hit a shipped app, and the one in the dump below -- the handler builds the overlay window, the message pump inside that build dispatches the second WM_HOTKEY, and the plugin locks the mutex its own thread is holding:

  1. Register two shortcuts, the first one creating a window, the second one arbitrary:
app.handle().plugin(tauri_plugin_global_shortcut::Builder::new().build())?;
// …
app.global_shortcut().on_shortcut("alt+w", move |app, _, e| {
    if e.state == ShortcutState::Pressed {
        let _ = tauri::WebviewWindowBuilder::new(app, "overlay", tauri::WebviewUrl::App("index.html".into()))
            .build();
    }
})?;
app.global_shortcut().on_shortcut("alt+q", |_, _, _| {})?;
  1. Press alt+w, then press alt+q (or alt+w again) while the first window is still coming up — a WebView2 cold start is around a second.

The process is dead, every time, on Windows. I have a 439 MB full dump of the frozen process taken with symbols, and the second press is already inside the first handler's stack — see the stack trace below.

Expected behavior

The second event is either delivered or dropped; it must not deadlock the main thread. In other words, the plugin should not hold shortcuts while it calls out to user code.

Full tauri info output
[✔] Environment
[✔] Environment
    - OS: Windows 10.0.26200 x86_64 (X64)
    ✔ WebView2: 153.0.4234.32
    ✔ MSVC: Visual Studio Professional 2026
    ✔ rustc: 1.98.0 (88d9e12ae 2026-08-18)
    ✔ cargo: 1.98.0 (797e8a9bc 2026-08-05)
    ✔ rustup: 1.29.1 (d95a37b6a 2026-08-13)
    ✔ Rust toolchain: stable-x86_64-pc-windows-msvc (default)
    - node: 26.8.1
    - pnpm: 12.4.1
    - npm: 11.19.0
    - bun: 1.4.2
    - deno: deno 2.9.6

[-] Packages
    - tauri 🦀: 2.11.5
    - tauri-build 🦀: 2.6.3
    - wry 🦀: 0.55.1, (outdated, latest: 0.57.0)
    - tao 🦀: 0.35.3, (outdated, latest: 0.37.0)
    - @tauri-apps/api  ⱼₛ: 2.11.1
    - @tauri-apps/cli  ⱼₛ: 2.11.4

[-] Plugins
    - tauri-plugin-log 🦀: 2.9.0, (outdated, latest: 2.9.1)
    - @tauri-apps/plugin-log  ⱼₛ: 2.9.0 (outdated, latest: 2.9.1)
    - tauri-plugin-fs 🦀: 2.5.1, (outdated, latest: 2.5.2)
    - @tauri-apps/plugin-fs  ⱼₛ: 2.5.1 (outdated, latest: 2.5.2)
    - tauri-plugin-shell 🦀: 2.3.5, (outdated, latest: 2.3.6)
    - @tauri-apps/plugin-shell  ⱼₛ: 2.3.5 (outdated, latest: 2.3.6)
    - tauri-plugin-dialog 🦀: 2.7.2, (outdated, latest: 2.7.3)
    - @tauri-apps/plugin-dialog  ⱼₛ: 2.7.2 (outdated, latest: 2.7.3)
    - tauri-plugin-http 🦀: 2.5.9, (outdated, latest: 2.6.1)
    - @tauri-apps/plugin-http  ⱼₛ: 2.5.9 (outdated, latest: 2.6.1)
    - tauri-plugin-sql 🦀: 2.4.0, (outdated, latest: 2.4.1)
    - @tauri-apps/plugin-sql  ⱼₛ: 2.4.0 (outdated, latest: 2.4.1)
    - tauri-plugin-global-shortcut 🦀: 2.3.2
    - @tauri-apps/plugin-global-shortcut  ⱼₛ: 2.3.2
    - tauri-plugin-autostart 🦀: 2.5.1
    - @tauri-apps/plugin-autostart  ⱼₛ: 2.5.1
    - tauri-plugin-single-instance 🦀: 2.4.3, (outdated, latest: 2.4.4)
    - @tauri-apps/plugin-single-instance  ⱼₛ: not installed!
    - tauri-plugin-clipboard-manager 🦀: 2.3.2, (outdated, latest: 2.3.3)
    - @tauri-apps/plugin-clipboard-manager  ⱼₛ: 2.3.2 (outdated, latest: 2.3.3)
    - tauri-plugin-process 🦀: 2.3.1
    - @tauri-apps/plugin-process  ⱼₛ: 2.3.1
    - tauri-plugin-updater 🦀: 2.10.1, (outdated, latest: 2.11.0)
    - @tauri-apps/plugin-updater  ⱼₛ: 2.10.1 (outdated, latest: 2.11.0)
    - tauri-plugin-store 🦀: 2.4.4, (outdated, latest: 2.4.5)
    - @tauri-apps/plugin-store  ⱼₛ: 2.4.4 (outdated, latest: 2.4.5)
    - tauri-plugin-os 🦀: 2.3.2
    - @tauri-apps/plugin-os  ⱼₛ: 2.3.2
    - tauri-plugin-notification 🦀: 2.3.3, (outdated, latest: 2.4.0)
    - @tauri-apps/plugin-notification  ⱼₛ: 2.3.3 (outdated, latest: 2.4.0)
    - tauri-plugin-opener 🦀: 2.5.4, (outdated, latest: 2.5.5)
    - @tauri-apps/plugin-opener  ⱼₛ: 2.5.4 (outdated, latest: 2.5.5)

[-] App
    - build-type: bundle
    - CSP: default-src * data: ; img-src * 'self' asset: https: data: ; style-src * 'unsafe-inline'; worker-src 'self' blob: ; script-src * 'unsafe-eval';
    - frontendDist: ../dist
    - devUrl: http://localhost:1420/
    - framework: React
    - bundler: Vite

Also: global-hotkey 0.8.0. The same code is present in the current v2 branch, 4b5c9549c96f099bbb3f77e4f3fac0f01748902e, plugins/global-shortcut/src/lib.rs#L417.

Stack trace

Main thread of the frozen process, from the dump (app frames are LLVM-mangled and some are folded into the wrong name by ICF; the four frames that matter I checked by disassembling the addresses):

ntdll!NtWaitForAlertByThreadId
ntdll!RtlWaitOnAddress
KERNELBASE!WaitOnAddress
gloss!std::sys::sync::mutex::futex::Mutex::lock_contended+0x84              ← the plugin's lock()
gloss!…28tauri_plugin_global_shortcut…Builder…build00…+0x1a5                ← the closure set_event_handler was given
gloss!…global_hotkey…GlobalHotKeyManager::register+0x659                    ← folded name; really global-hotkey's window procedure
user32!UserCallWinProcCheckWow+0x356                                        ← `cmp edx, 312h` (= WM_HOTKEY) is in there,
user32!DispatchMessageWorker+0x1dd                                             and it calls GlobalHotKeyEvent::send
gloss!…webview2_com::wait_with_pump+0xe5                                    ← the nested pump, inside WebView2 creation
gloss!…AddScriptToExecuteOnDocumentCreatedCompletedHandler::wait_for_async_operation+0x24b
… tauri WindowBuilder::with_webview → the app's own window build …

What makes it a self-deadlock rather than a fight between two threads — only one of the dump's 94 threads touches this lock, and it is this one:

0:000> dd 000001f9`bc27d970 L2                  ; the lock RtlWaitOnAddress is parked on
000001f9`bc27d970  ffff0002 0000ffff            ; byte state = 2 (locked, with waiters)
0:000> dq 000001f9`bc27d960 L2                  ; 16 bytes before it: the ArcInner
000001f9`bc27d960  00000000`00000002 00000000`00000001   ; strong = 2, weak = 1 — Arc::new(Mutex::new(store)) + shortcuts.clone()
0:000> s -q <that thread's stack> 000001f9`bc27d970
00000080`bf5a2e90  …                            ; rcx/rsi/r14 of lock_contended: what it is waiting for
00000080`bf5ac598  …                            ; the *same* pointer in an older frame of the same stack
                                                ; = [rbp-0x18] of the outer build() closure, the slot its
                                                ; own unlock path does `xchg al, byte ptr [rcx]` on, i.e. its live guard

And the two events that produced those two runs of the same closure, both dispatched from the pump:

WM_HOTKEY (0x312) id 0x10029, lParam 0x00570001   (Alt+W)   ← outer run; it holds the lock
WM_HOTKEY (0x312) id 0x10023, lParam 0x00510001   (Alt+Q)   ← dispatched by the inner pump; it waits for it
Additional context

This is not the documented "window in a handler" deadlock. #1923 was closed with the WebviewWindowBuilder known issue -- that creating a window from a synchronous command or event handler deadlocks on Windows, which is wry#583, where the WebView2 controller creation itself never completes. What the dump shows is a different lock:

  • The main thread is parked on the plugin's shortcuts mutex, with the guard for that same mutex on its own stack, underneath the plugin's build() closure -- no create_controller, no WebView2 frame anywhere in it.
  • The second WM_HOTKEY is blocked before any user handler runs, so no app-side threading practice changes this outcome.
  • And the minimal repro above needs no webview at all, which is really the point here: the plugin's API cannot be called from the plugin's own callbacks.

For what it is worth, I have also moved my app's window creation out of the handler, the way that guidance says. The guidance is fine; what makes a mistake in it unrecoverable is the lock. A deadlock from plugin-internal locking has been treated as a bug here before: #3394 ("plugin-store deadlock on RunEvent::Exit save") was diagnosed the same day and fixed in #3395.

Where the lock is held. 2.3.2 lib.rs:416-425 (identical in the v2 branch at the permalink above):

GlobalHotKeyEvent::set_event_handler(Some(move |e: GlobalHotKeyEvent| {
    if let Some(shortcut) = shortcuts_.lock().unwrap().get(&e.id) {
        if let Some(handler) = &shortcut.handler {
            handler(&app_handle, &shortcut.shortcut, e);   // ← `shortcuts` is still locked here
        }
        if let Some(handler) = &handler {
            handler(&app_handle, &shortcut.shortcut, e);
        }
    }
}));

The guard comes from the scrutinee of an if let, so it is dropped at the end of the whole if let, not at the end of the condition. The 2024 edition rescoping does not fix this particular direction of it: per the edition guide it shortens the temporary "to the point where the then-block is completely evaluated or the program control enters the else block", so the handler call inside the then-block is still made with the guard alive. That page's own example is this exact footgun with a lock in an if let scrutinee: https://doc.rust-lang.org/edition-guide/rust-2024/temporary-if-let-scope.html.

Suggested fix — take what the handlers need, let the guard go, then call them:

                 GlobalHotKeyEvent::set_event_handler(Some(move |e: GlobalHotKeyEvent| {
-                    if let Some(shortcut) = shortcuts_.lock().unwrap().get(&e.id) {
-                        if let Some(handler) = &shortcut.handler {
-                            handler(&app_handle, &shortcut.shortcut, e);
-                        }
-                        if let Some(handler) = &handler {
-                            handler(&app_handle, &shortcut.shortcut, e);
-                        }
-                    }
+                    // Take a copy of what the handlers need and let the guard go before
+                    // calling them: `shortcuts` is not reentrant, and a handler may pump
+                    // messages -- which lets the next shortcut event re-enter this closure
+                    // and wait for the lock its own thread is still holding.
+                    let entry = shortcuts_
+                        .lock()
+                        .unwrap()
+                        .get(&e.id)
+                        .map(|s| (s.shortcut, s.handler.clone()));
+
+                    if let Some((shortcut, per_shortcut)) = entry {
+                        if let Some(handler) = &per_shortcut {
+                            handler(&app_handle, &shortcut, e);
+                        }
+                        // The one from `Builder::with_handler`. It used to share the name of
+                        // the per-shortcut binding above, which is why it read as if it were
+                        // the same handler twice.
+                        if let Some(handler) = &handler {
+                            handler(&app_handle, &shortcut, e);
+                        }
+                    }
                 }));

(While there: the second if let Some(handler) = &handler shadows the per-shortcut one and reads the captured handler from Builder, which is why with_handler only fires for ids that are in the map. Might be deliberate, but it looks like the source of #1748/#1243-style confusion.)

A test that would have caught it, and needs no real hotkey: in-crate, build an app with the plugin, register a shortcut whose handler calls back into the plugin (app.global_shortcut().is_registered("alt+q") — the same mutex), then drive the registry the way the window procedure does by calling GlobalHotKeyEvent::send(..) (it is pub(crate), lib.rs:126). Today that test hangs; with the guard dropped first it passes.

What the app does meanwhile. I work around it on the app side by answering the shortcut callback first and running the handler's work as its own event-loop task (tauri::async_runtime::spawn + run_on_main_thread, since from the main thread run_on_main_thread runs inline), plus a one-session-at-a-time guard so a second press cannot start a second window on top of a half-built one. That removes the deadlock by construction — the lock is released before anything can pump — but the reentrancy is really the plugin's to fix, and it stays a trap for every handler that pumps. Happy to test a patched build against this app if that helps.

Dominant language
Rust
Stars
1.8k
Forks
603
Avg merge
4d 1h
Merged PRs (30d)
11

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from tauri-apps/plugins-workspace

All issues in tauri-apps/plugins-workspace

Similar issues

More Rust issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.