cjpais/Handy · error
failed to create quit item
Error message
failed to create quit item
What it means
update_tray_menu() rebuilds the whole tray context menu on every tray-state or language change using Tauri's tray API backed by the muda menu library. MenuItem::with_id registers a native menu item with the app's menu manager; this .expect panics when that registration returns Err. On Linux the tray/menu path goes through DBusMenu, so it fails when no StatusNotifierWatcher/registrar is reachable; on other platforms it is nearly always a shutdown race where the event loop or app handle is being torn down.
Solutions
- Reproduce the environment first: on Linux run a tray provider (KDE Plasma, waybar with tray, GNOME AppIndicator extension) and confirm the panic disappears
- Convert update_tray_menu to return Result and log-and-keep the previous menu instead of .expect, so one failed rebuild cannot take down the app
- Guard rebuilds during shutdown: skip update_tray_menu once an exit flag is set, and debounce state-change rebuilds
- Keep tauri and muda versions in lockstep when upgrading, since the menu-manager API and its error surface change between releases
Example fix
// before
let quit_i = MenuItem::with_id(app, "quit", &strings.quit, true, quit_accelerator)
.expect("failed to create quit item");
// after
let quit_i = match MenuItem::with_id(app, "quit", &strings.quit, true, quit_accelerator) {
Ok(item) => item,
Err(e) => {
error!("Failed to create quit item, keeping previous tray menu: {e}");
return;
}
}; Defensive patterns
Strategy: fallback
Try / catch
// In update_tray_menu, treat any item-creation Err as 'abort rebuild, keep previous menu':
let quit_i = match MenuItem::with_id(app, "quit", &strings.quit, true, quit_accelerator) {
Ok(i) => i,
Err(e) => { error!("tray rebuild failed (quit item): {e}"); return; }
}; Prevention
- Never .expect on muda/Tauri menu constructors in code that runs on every state change — return Result and keep the previous menu on failure
- On Linux, verify a StatusNotifierWatcher exists (qdbus org.kde.StatusNotifierWatcher) before relying on tray code in tests
- Serialize tray rebuilds through one task/queue so overlapping state changes cannot interleave
- Skip rebuilds once a shutdown flag is set
When it happens
Trigger: Calling update_tray_menu on Linux under GNOME (no tray extension), sway/hyprland without a waybar-like StatusNotifierWatcher, or a headless/CI session; a tray-state change (Idle->Recording) or language change firing while the app is exiting, so MenuItem::with_id(app, "quit", ...) hits a dead menu manager.
Common situations: Testing Handy on minimal Wayland compositors or GNOME without an AppIndicator extension; systemd services or containers that auto-start the app without a session bus; races during app quit when a final update_tray_menu sneaks in after teardown.
Related errors
- failed to create separator
- failed to create unload model item
- failed to create cancel item
- failed to create menu
- failed to create model item
AI-assisted analysis of cjpais/Handy@fbd4e15fa1 (2026-08-17).
Data as JSON: /api/errors/9f3e68c7d2f65fa2.
Report an issue: GitHub.
Appendix: source
Thrown at src-tauri/src/tray.rs:241
/// (theme, Secure Input warning). The recording state itself is preserved.
pub fn refresh_tray_icon(app: &AppHandle) {
sync_tray(app);
}
/// Re-syncs the tray after something the menu depends on changed (model
/// list/selection/loaded state, language, settings).
pub fn update_tray_menu(app: &AppHandle) {
sync_tray(app);
}
/// Records the current desired tray state and schedules one apply on the main
/// thread (or lets an already-pending apply pick it up). Never blocks on the
/// main thread.
///
/// The snapshot (settings, model list, loaded state) is computed on the
/// *calling* thread on purpose: the main-thread applier must not take manager
/// locks that a worker may hold across slow work (see #1716).
pub fn sync_tray(app: &AppHandle) {
sync_tray_with(app, |_| {});
}
fn sync_tray_with(app: &AppHandle, update: impl FnOnce(&mut TrayInner)) {
let Some(state) = app.try_state::<TrayState>() else {
return;
};
// Record intent and claim a sequence number in one critical section, so
// sequence order == the order in which state changes were requested.
let (seq, icon_state) = {
let mut inner = state.lock();
update(&mut inner);
inner.next_seq += 1;
(inner.next_seq, inner.icon_state)
};
// Tray not built yet (early secure-input monitor callbacks). The intentView on GitHub (pinned to fbd4e15fa1)