tauri-apps / tauri-apps/plugins-workspace
global-shortcut: the shortcuts mutex is held while a handler runs, so a handler that pumps messages deadlocks the app
- Dominant language
- Rust
- Stars
- 1.8k
- Forks
- 602
- Avg merge
- 4d 14h
- Merged PRs (30d)
- 9
Description
### 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:**
```rust
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:
```rust
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", |_, _, _| {})?;
```
2. 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 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):
```rust
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: .
**Suggested fix** — take what the handlers need, let the guard go, then call them:
```diff
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.
Contributor guide
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.
Assessment
- Tech stack
- rust
- Domain
- desktop-dev
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 84/100