Files
Lumotia/src-tauri/src/commands/hotkey.rs
Jake 1068ad9c7d agent: code-atomiser-fix — hotkey supervisor rearchitecture (Race-1, Race-2, TOCTOU)
Fixes three interlocking concurrency leaks in the evdev hotkey listener
flagged by the atomiser full-sweep. Every spawned task is now owned by a
SupervisorHandle that broadcasts cooperative shutdown and joins every
JoinHandle with a 2s per-task timeout on stop(). Per-device attachment is
now insert-before-spawn under one mutex hold, closing the TOCTOU window.
The Tauri command layer now stores the forwarder JoinHandle alongside
the listener so reconfigures join it cleanly instead of leaking one
permanent forwarder per hotkey change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:21:50 +01:00

167 lines
5.6 KiB
Rust

use std::sync::Arc;
use tauri::Emitter;
use tokio::sync::{mpsc, Mutex};
use tokio::task::JoinHandle;
use lumotia_hotkey::{EvdevHotkeyListener, HotkeyCombo, HotkeyEvent};
/// A live evdev listener and the JoinHandle for the forwarder task that
/// drains its event channel into Tauri's event bus. They are stored as a
/// pair so a reconfigure can join BOTH before installing a replacement —
/// the previous implementation leaked the forwarder on every reconfigure
/// (atomiser Race-2), causing duplicate Pressed/Released emits to the
/// frontend.
struct ActiveListener {
listener: EvdevHotkeyListener,
forwarder: JoinHandle<()>,
}
/// Managed state for the evdev hotkey listener.
pub struct HotkeyState {
active: Arc<Mutex<Option<ActiveListener>>>,
}
impl HotkeyState {
pub fn new() -> Self {
Self {
active: Arc::new(Mutex::new(None)),
}
}
}
/// Check whether the current session is running on Wayland.
#[tauri::command]
pub fn is_wayland_session() -> bool {
std::env::var("WAYLAND_DISPLAY").is_ok()
|| std::env::var("XDG_SESSION_TYPE")
.map(|v| v == "wayland")
.unwrap_or(false)
}
/// Check whether evdev hotkey capture is available (user in `input` group, etc.).
#[tauri::command]
pub fn check_hotkey_access() -> Result<(), String> {
lumotia_hotkey::check_evdev_access()
}
/// Tear down an `ActiveListener`. Stops the underlying evdev listener
/// first (which drops every internal `event_tx` clone via the supervisor
/// shutdown path) and then awaits the forwarder JoinHandle. Bounded by a
/// timeout so a stuck forwarder cannot block the caller.
async fn shutdown_active(active: ActiveListener) {
active.listener.stop().await;
// After listener.stop() returns, every device-listener task has
// dropped its event_tx clone. The forwarder's mpsc receiver therefore
// returns None and the task exits naturally. Bound the wait so a
// misbehaving forwarder cannot wedge the caller.
match tokio::time::timeout(std::time::Duration::from_secs(2), active.forwarder).await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::warn!(
target: "lumotia_hotkey",
error = %e,
"hotkey forwarder task panicked"
);
}
Err(_) => {
tracing::warn!(
target: "lumotia_hotkey",
"hotkey forwarder did not drain within 2s; detaching"
);
}
}
}
/// Start the evdev global hotkey listener. Emits "lumotia:hotkey-pressed" and
/// "lumotia:hotkey-released" events to the frontend.
///
/// If a listener is already running, it is stopped first AND its
/// forwarder is joined before the replacement is installed. This is the
/// fix for atomiser Race-2: the previous implementation spawned a fresh
/// forwarder on every reconfigure without ever joining the old one, so
/// every hotkey change leaked a permanent task that received duplicate
/// events from the (also-leaked) old device listeners.
#[tauri::command]
pub async fn start_evdev_hotkey(
app: tauri::AppHandle,
state: tauri::State<'_, HotkeyState>,
hotkey: String,
) -> Result<(), String> {
let combo = HotkeyCombo::from_tauri_str(&hotkey)
.ok_or_else(|| format!("Cannot parse hotkey: {hotkey}"))?;
// Stop existing listener (and join its forwarder) if any. Done
// BEFORE creating the new listener so the old event_tx is fully
// dropped and the new event_rx is the only live receiver.
let mut guard = state.active.lock().await;
if let Some(existing) = guard.take() {
// Release the lock for the await so other commands aren't held
// up if shutdown_active takes the full 2s timeout. Reacquire
// after.
drop(guard);
shutdown_active(existing).await;
guard = state.active.lock().await;
}
let (event_tx, mut event_rx) = mpsc::channel::<HotkeyEvent>(64);
let listener = EvdevHotkeyListener::start(combo, event_tx).await;
// Forward evdev events to Tauri event bus. The JoinHandle is stored
// alongside the listener so the next reconfigure (or explicit stop)
// can join it cleanly.
let app_clone = app.clone();
let forwarder = tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
match event {
HotkeyEvent::Pressed => {
let _ = app_clone.emit("lumotia:hotkey-pressed", ());
}
HotkeyEvent::Released => {
let _ = app_clone.emit("lumotia:hotkey-released", ());
}
}
}
tracing::debug!(
target: "lumotia_hotkey",
"forwarder exiting; all event_tx senders dropped"
);
});
*guard = Some(ActiveListener {
listener,
forwarder,
});
Ok(())
}
/// Update the hotkey combo on a running listener.
#[tauri::command]
pub async fn update_evdev_hotkey(
state: tauri::State<'_, HotkeyState>,
hotkey: String,
) -> Result<(), String> {
let combo = HotkeyCombo::from_tauri_str(&hotkey)
.ok_or_else(|| format!("Cannot parse hotkey: {hotkey}"))?;
let guard = state.active.lock().await;
if let Some(ref active) = *guard {
active.listener.set_hotkey(combo);
Ok(())
} else {
Err("Hotkey listener not running".to_string())
}
}
/// Stop the evdev hotkey listener.
#[tauri::command]
pub async fn stop_evdev_hotkey(state: tauri::State<'_, HotkeyState>) -> Result<(), String> {
let mut guard = state.active.lock().await;
if let Some(active) = guard.take() {
drop(guard);
shutdown_active(active).await;
}
Ok(())
}