cjpais/Handy · error

failed to create menu

Error message

failed to create menu

What it means

After creating all children, the Recording/Transcribing branch assembles them with Menu::with_items(...).expect("failed to create menu"). This final assembly fails if the native backend rejects the menu object itself or any passed child is in an invalid state. Because it runs on the recording-state rebuild, a panic here kills an active dictation session — the worst-timed crash in the tray module.

Solutions

  1. Ensure a system tray exists when testing recording flows on Linux
  2. Wrap the whole update_tray_menu in a Result-returning helper: on Menu::with_items failure, log, keep tray.set_menu untouched, and leave the icon state unchanged
  3. Serialize rebuilds through one channel/task and drop rebuild requests that arrive during teardown
  4. Consider building the menu without accelerators as a fallback on repeated failures

Example fix

// before
Menu::with_items(app, &[&version_i, &separator(), &cancel_i, /* ... */])
    .expect("failed to create menu")

// after
match Menu::with_items(app, &[&version_i, &separator(), &cancel_i, /* ... */]) {
    Ok(menu) => menu,
    Err(e) => {
        error!("Failed to assemble recording tray menu, keeping previous: {e}");
        return;
    }
}
Defensive patterns

Strategy: fallback

Try / catch

match Menu::with_items(app, &[&version_i, &separator(), &cancel_i, /* ... */]) {
    Ok(menu) => menu,
    Err(e) => { error!("recording menu assembly failed: {e}"); return; }
}

Prevention

When it happens

Trigger: Toggling recording on a Linux desktop without a functioning StatusNotifierWatcher/DBusMenu; passing the accelerator-bearing items (settings/quit carry Ctrl+Q style accelerators) to a backend that rejects them; rebuild racing app shutdown.

Common situations: Tray-less Wayland sessions; GNOME without extensions; automated tests driving state changes headlessly.

Related errors


AI-assisted analysis of cjpais/Handy@fbd4e15fa1 (2026-08-17). Data as JSON: /api/errors/b17ac3a653c91cd6. Report an issue: GitHub.

Appendix: source

Thrown at src-tauri/src/tray.rs:302

    let schedule = {
        let mut inner = state.lock();
        if let Some(image) = loaded_icon {
            inner.icons.insert(desired.icon_path, image);
        }
        if seq < inner.desired_seq {
            // A request triggered after this one already stored its snapshot
            // (and scheduled an apply). Ours is stale; drop it.
            trace!(
                "tray sync: request {seq} superseded by {}",
                inner.desired_seq
            );
            return;
        }
        inner.desired = Some(desired);
        inner.desired_seq = seq;
        // If an apply is already pending it will read the snapshot we just
        // stored; otherwise schedule one.
        !std::mem::replace(&mut inner.pending, true)
    };

    if schedule {
        post_apply(app);
    } else {
        trace!("tray sync: apply already pending");
    }
}

fn compute_desired(app: &AppHandle, icon_state: TrayIconState) -> TrayDesired {
    let settings = settings::get_settings(app);
    let theme = get_current_theme(app);
    let warning = crate::secure_input::tray_warning_active(app);
    let model_loaded = app.state::<Arc<TranscriptionManager>>().is_model_loaded();

    let mut downloaded_models: Vec<(String, String)> = app
        .state::<Arc<ModelManager>>()
        .get_available_models()

View on GitHub (pinned to fbd4e15fa1)