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>
This commit is contained in:
@@ -8,8 +8,19 @@
|
||||
//! - Device hotplug via `notify` watching `/dev/input/`
|
||||
//! - Retry loop for udev permission propagation on new devices
|
||||
//! - Per-device async event streams
|
||||
//!
|
||||
//! ## Lifecycle
|
||||
//!
|
||||
//! Every task this module spawns is owned by a
|
||||
//! [`crate::supervisor::SupervisorHandle`] living inside the
|
||||
//! [`EvdevHotkeyListener`]. On `stop()`, the supervisor sends a broadcast
|
||||
//! shutdown signal and awaits every `JoinHandle` with a bounded timeout,
|
||||
//! so a reconfigure cannot leave orphaned listeners alive (which would
|
||||
//! otherwise hold `event_tx` clones forever and emit duplicate events to
|
||||
//! the frontend). See `supervisor.rs` for the supervisor and
|
||||
//! `tests/listener_lifecycle.rs` for the regression tests.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -17,6 +28,7 @@ use evdev::{AttributeSetRef, Device, InputEventKind, Key};
|
||||
use notify::{recommended_watcher, EventKind, RecursiveMode, Watcher};
|
||||
use tokio::sync::{mpsc, watch, Mutex};
|
||||
|
||||
use crate::supervisor::SupervisorHandle;
|
||||
use crate::HotkeyCombo;
|
||||
|
||||
/// Events emitted by the hotkey listener.
|
||||
@@ -28,12 +40,27 @@ pub enum HotkeyEvent {
|
||||
Released,
|
||||
}
|
||||
|
||||
/// Shared map of attached evdev devices. Keyed by path so attach is
|
||||
/// idempotent. Membership-only marker — the actual `JoinHandle` for each
|
||||
/// device listener task lives in the supervisor. Insert-before-spawn
|
||||
/// under one mutex hold closes the TOCTOU window the previous design had.
|
||||
type TrackedDevices = Arc<Mutex<HashMap<PathBuf, ()>>>;
|
||||
|
||||
/// Manages evdev device listeners and hotplug detection.
|
||||
///
|
||||
/// All spawned tasks are owned by an internal
|
||||
/// [`SupervisorHandle`]. On `stop()` (or `Drop`) every task receives a
|
||||
/// broadcast shutdown signal and is joined with a per-task timeout so a
|
||||
/// reconfigure cannot leak listeners.
|
||||
pub struct EvdevHotkeyListener {
|
||||
/// Send a new hotkey config to all listener tasks.
|
||||
hotkey_tx: watch::Sender<Option<HotkeyCombo>>,
|
||||
/// Signals all tasks to shut down.
|
||||
shutdown_tx: mpsc::Sender<()>,
|
||||
/// Tracks every spawned task. Cloned into spawn sites so per-device
|
||||
/// retry tasks can register their own children.
|
||||
supervisor: SupervisorHandle,
|
||||
/// Set to `true` once `stop()` has run so `Drop` skips its
|
||||
/// best-effort shutdown signal.
|
||||
stopped: bool,
|
||||
}
|
||||
|
||||
impl EvdevHotkeyListener {
|
||||
@@ -43,101 +70,71 @@ impl EvdevHotkeyListener {
|
||||
/// The listener spawns:
|
||||
/// 1. One async task per input device that has the target key
|
||||
/// 2. A watcher task that detects new devices via inotify on `/dev/input/`
|
||||
pub fn start(combo: HotkeyCombo, event_tx: mpsc::Sender<HotkeyEvent>) -> Self {
|
||||
pub async fn start(combo: HotkeyCombo, event_tx: mpsc::Sender<HotkeyEvent>) -> Self {
|
||||
let (hotkey_tx, hotkey_rx) = watch::channel(Some(combo));
|
||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||
let tracked: TrackedDevices = Arc::new(Mutex::new(HashMap::new()));
|
||||
let supervisor = SupervisorHandle::new();
|
||||
|
||||
let tracked = Arc::new(Mutex::new(HashSet::<PathBuf>::new()));
|
||||
|
||||
// Spawn initial device listeners
|
||||
let hotkey_rx_clone = hotkey_rx.clone();
|
||||
let event_tx_clone = event_tx.clone();
|
||||
let tracked_clone = tracked.clone();
|
||||
tokio::spawn(async move {
|
||||
scan_and_attach(&hotkey_rx_clone, &event_tx_clone, &tracked_clone).await;
|
||||
});
|
||||
|
||||
// Spawn hotplug watcher
|
||||
let hotkey_rx_hotplug = hotkey_rx.clone();
|
||||
let event_tx_hotplug = event_tx.clone();
|
||||
let tracked_hotplug = tracked.clone();
|
||||
tokio::spawn(async move {
|
||||
let (notify_tx, mut notify_rx) = mpsc::channel::<PathBuf>(32);
|
||||
|
||||
// notify watcher runs on a blocking thread internally.
|
||||
// If inotify itself is unavailable (rare: minimal containers,
|
||||
// some BSDs misconfigured as Linux) we degrade to "no
|
||||
// hotplug detection" rather than panicking the task — the
|
||||
// initial scan_and_attach pass above still picks up all
|
||||
// devices that exist at startup.
|
||||
let _watcher = {
|
||||
let notify_tx = notify_tx.clone();
|
||||
let watcher = recommended_watcher(move |res: Result<notify::Event, _>| {
|
||||
if let Ok(event) = res {
|
||||
if matches!(event.kind, EventKind::Create(_)) {
|
||||
for path in event.paths {
|
||||
if is_event_device(&path) {
|
||||
let _ = notify_tx.blocking_send(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
match watcher {
|
||||
Ok(mut w) => {
|
||||
match w.watch(Path::new("/dev/input"), RecursiveMode::NonRecursive) {
|
||||
Ok(()) => Some(w),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"cannot watch /dev/input; hotplug detection disabled, \
|
||||
devices present at startup still work"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"cannot create inotify watcher; hotplug detection disabled"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
// Spawn initial scanner. Walks /dev/input once and attaches every
|
||||
// matching device. After it completes the hotplug watcher (below)
|
||||
// is responsible for keeping the attachment set in sync.
|
||||
{
|
||||
let hotkey_rx = hotkey_rx.clone();
|
||||
let event_tx = event_tx.clone();
|
||||
let tracked = tracked.clone();
|
||||
let supervisor_inner = supervisor.clone();
|
||||
let mut shutdown_rx = supervisor.subscribe();
|
||||
let scanner_handle = tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
Some(path) = notify_rx.recv() => {
|
||||
// Retry opening with backoff — udev permissions propagate
|
||||
// asynchronously after device creation (whisper-overlay pattern)
|
||||
let hotkey_rx = hotkey_rx_hotplug.clone();
|
||||
let event_tx = event_tx_hotplug.clone();
|
||||
let tracked = tracked_hotplug.clone();
|
||||
tokio::spawn(async move {
|
||||
for attempt in 0..5 {
|
||||
if attempt > 0 {
|
||||
tokio::time::sleep(
|
||||
std::time::Duration::from_secs(1)
|
||||
).await;
|
||||
}
|
||||
if try_attach_device(
|
||||
&path, &hotkey_rx, &event_tx, &tracked,
|
||||
).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
_ = scan_and_attach(
|
||||
&hotkey_rx,
|
||||
&event_tx,
|
||||
&tracked,
|
||||
&supervisor_inner,
|
||||
) => {}
|
||||
_ = shutdown_rx.recv() => {
|
||||
tracing::debug!(
|
||||
target: "lumotia_hotkey",
|
||||
"scanner received shutdown signal mid-scan"
|
||||
);
|
||||
}
|
||||
_ = shutdown_rx.recv() => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
supervisor.register("scanner", scanner_handle).await;
|
||||
}
|
||||
|
||||
// Spawn hotplug watcher. Hands the supervisor handle through so
|
||||
// it can register retry tasks it spawns.
|
||||
{
|
||||
let hotkey_rx = hotkey_rx.clone();
|
||||
let event_tx = event_tx.clone();
|
||||
let tracked = tracked.clone();
|
||||
let supervisor_inner = supervisor.clone();
|
||||
let mut shutdown_rx = supervisor.subscribe();
|
||||
let hotplug_handle = tokio::spawn(async move {
|
||||
run_hotplug_watcher(
|
||||
hotkey_rx,
|
||||
event_tx,
|
||||
tracked,
|
||||
supervisor_inner,
|
||||
&mut shutdown_rx,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
supervisor.register("hotplug", hotplug_handle).await;
|
||||
}
|
||||
|
||||
let task_count = supervisor.task_count().await;
|
||||
tracing::info!(
|
||||
target: "lumotia_hotkey",
|
||||
task_count = task_count,
|
||||
"supervisor started"
|
||||
);
|
||||
|
||||
Self {
|
||||
hotkey_tx,
|
||||
shutdown_tx,
|
||||
supervisor,
|
||||
stopped: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,9 +145,121 @@ impl EvdevHotkeyListener {
|
||||
}
|
||||
|
||||
/// Stop all listeners and clean up.
|
||||
pub async fn stop(&self) {
|
||||
///
|
||||
/// Consumes the listener so it cannot be reused. Awaits every
|
||||
/// supervised task with a per-task timeout (see
|
||||
/// [`SupervisorHandle::shutdown`]); a stuck task is logged and
|
||||
/// detached rather than blocking the caller indefinitely.
|
||||
pub async fn stop(mut self) {
|
||||
// Signal None first so device listeners exit their loop cleanly
|
||||
// without waiting for the broadcast subscription select arm.
|
||||
let _ = self.hotkey_tx.send(None);
|
||||
let _ = self.shutdown_tx.send(()).await;
|
||||
self.supervisor.shutdown().await;
|
||||
self.stopped = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort shutdown on drop. Async drop isn't available in stable
|
||||
/// Rust, so we only fire the broadcast — we cannot await JoinHandles
|
||||
/// here. Tasks subscribed to the broadcast see the signal and exit
|
||||
/// cooperatively; their JoinHandles detach but the runtime reclaims them
|
||||
/// once they finish. The intended path is always explicit `stop().await`
|
||||
/// before drop.
|
||||
impl Drop for EvdevHotkeyListener {
|
||||
fn drop(&mut self) {
|
||||
if !self.stopped {
|
||||
self.supervisor.signal_shutdown_nonblocking();
|
||||
let _ = self.hotkey_tx.send(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hotplug watcher loop. Listens for inotify events on `/dev/input/`
|
||||
/// and dispatches a retry task per new device path. Cooperatively
|
||||
/// shuts down on broadcast.
|
||||
async fn run_hotplug_watcher(
|
||||
hotkey_rx: watch::Receiver<Option<HotkeyCombo>>,
|
||||
event_tx: mpsc::Sender<HotkeyEvent>,
|
||||
tracked: TrackedDevices,
|
||||
supervisor: SupervisorHandle,
|
||||
shutdown_rx: &mut tokio::sync::broadcast::Receiver<()>,
|
||||
) {
|
||||
let (notify_tx, mut notify_rx) = mpsc::channel::<PathBuf>(32);
|
||||
|
||||
// notify watcher runs on a blocking thread internally.
|
||||
// If inotify itself is unavailable (rare: minimal containers,
|
||||
// some BSDs misconfigured as Linux) we degrade to "no
|
||||
// hotplug detection" rather than panicking the task — the
|
||||
// initial scan_and_attach pass above still picks up all
|
||||
// devices that exist at startup.
|
||||
let _watcher = {
|
||||
let notify_tx = notify_tx.clone();
|
||||
let watcher = recommended_watcher(move |res: Result<notify::Event, _>| {
|
||||
if let Ok(event) = res {
|
||||
if matches!(event.kind, EventKind::Create(_)) {
|
||||
for path in event.paths {
|
||||
if is_event_device(&path) {
|
||||
let _ = notify_tx.blocking_send(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
match watcher {
|
||||
Ok(mut w) => match w.watch(Path::new("/dev/input"), RecursiveMode::NonRecursive) {
|
||||
Ok(()) => Some(w),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"cannot watch /dev/input; hotplug detection disabled, \
|
||||
devices present at startup still work"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"cannot create inotify watcher; hotplug detection disabled"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(path) = notify_rx.recv() => {
|
||||
// Retry opening with backoff — udev permissions propagate
|
||||
// asynchronously after device creation (whisper-overlay pattern).
|
||||
// The retry task subscribes to the broadcast so it exits
|
||||
// promptly on stop() even if it's mid-backoff.
|
||||
let hotkey_rx = hotkey_rx.clone();
|
||||
let event_tx = event_tx.clone();
|
||||
let tracked = tracked.clone();
|
||||
let supervisor_inner = supervisor.clone();
|
||||
let mut retry_shutdown_rx = supervisor.subscribe();
|
||||
let retry_handle = tokio::spawn(async move {
|
||||
for attempt in 0..5 {
|
||||
if attempt > 0 {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(
|
||||
std::time::Duration::from_secs(1)
|
||||
) => {}
|
||||
_ = retry_shutdown_rx.recv() => return,
|
||||
}
|
||||
}
|
||||
if try_attach_device(
|
||||
&path, &hotkey_rx, &event_tx, &tracked, &supervisor_inner,
|
||||
).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
supervisor.register("hotplug-retry", retry_handle).await;
|
||||
}
|
||||
_ = shutdown_rx.recv() => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +302,8 @@ pub fn check_access() -> Result<(), String> {
|
||||
async fn scan_and_attach(
|
||||
hotkey_rx: &watch::Receiver<Option<HotkeyCombo>>,
|
||||
event_tx: &mpsc::Sender<HotkeyEvent>,
|
||||
tracked: &Arc<Mutex<HashSet<PathBuf>>>,
|
||||
tracked: &TrackedDevices,
|
||||
supervisor: &SupervisorHandle,
|
||||
) {
|
||||
let input_dir = Path::new("/dev/input");
|
||||
let entries = match std::fs::read_dir(input_dir) {
|
||||
@@ -207,21 +317,31 @@ async fn scan_and_attach(
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if is_event_device(&path) {
|
||||
try_attach_device(&path, hotkey_rx, event_tx, tracked).await;
|
||||
try_attach_device(&path, hotkey_rx, event_tx, tracked, supervisor).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to open a device and start listening if it supports the target key.
|
||||
/// Returns true if the device was successfully attached.
|
||||
///
|
||||
/// Insert-into-tracked-then-spawn-then-release-mutex makes attachment
|
||||
/// atomic against concurrent hotplug + scan; the previous design's
|
||||
/// remove-after-task-exits window allowed double-attaches.
|
||||
async fn try_attach_device(
|
||||
path: &Path,
|
||||
hotkey_rx: &watch::Receiver<Option<HotkeyCombo>>,
|
||||
event_tx: &mpsc::Sender<HotkeyEvent>,
|
||||
tracked: &Arc<Mutex<HashSet<PathBuf>>>,
|
||||
tracked: &TrackedDevices,
|
||||
supervisor: &SupervisorHandle,
|
||||
) -> bool {
|
||||
let mut tracked_set = tracked.lock().await;
|
||||
if tracked_set.contains(path) {
|
||||
// Hold the mutex across the contains-check, the insert, AND the
|
||||
// spawn registration. This is the TOCTOU fix for Race-extra: the
|
||||
// previous implementation released the mutex before spawning and
|
||||
// before removal, leaving windows where concurrent scan + hotplug
|
||||
// could double-attach the same device.
|
||||
let mut tracked_map = tracked.lock().await;
|
||||
if tracked_map.contains_key(path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -249,27 +369,50 @@ async fn try_attach_device(
|
||||
"attached hotkey listener"
|
||||
);
|
||||
|
||||
tracked_set.insert(path.to_path_buf());
|
||||
drop(tracked_set);
|
||||
// Insert BEFORE spawning the listener task so a racing caller (the
|
||||
// scanner running concurrently with a hotplug retry, for example)
|
||||
// sees the entry and short-circuits.
|
||||
tracked_map.insert(path.to_path_buf(), ());
|
||||
|
||||
// Spawn a listener task for this device
|
||||
let hotkey_rx = hotkey_rx.clone();
|
||||
let event_tx = event_tx.clone();
|
||||
// Clone everything the spawned task needs before we release the
|
||||
// mutex so the release point is a single statement.
|
||||
let hotkey_rx_owned = hotkey_rx.clone();
|
||||
let event_tx_owned = event_tx.clone();
|
||||
let path_owned = path.to_path_buf();
|
||||
let tracked = tracked.clone();
|
||||
let tracked_for_cleanup = tracked.clone();
|
||||
let mut shutdown_rx = supervisor.subscribe();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = device_listener(device, hotkey_rx, event_tx).await {
|
||||
tracing::warn!(
|
||||
path = %path_owned.display(),
|
||||
error = %e,
|
||||
"device listener ended"
|
||||
);
|
||||
let listener_handle = tokio::spawn(async move {
|
||||
let listener_fut = device_listener(device, hotkey_rx_owned, event_tx_owned);
|
||||
tokio::select! {
|
||||
res = listener_fut => {
|
||||
if let Err(e) = res {
|
||||
tracing::warn!(
|
||||
path = %path_owned.display(),
|
||||
error = %e,
|
||||
"device listener ended"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.recv() => {
|
||||
tracing::debug!(
|
||||
target: "lumotia_hotkey",
|
||||
path = %path_owned.display(),
|
||||
"device listener received shutdown signal"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Remove from tracked set so hotplug can re-attach if reconnected
|
||||
tracked.lock().await.remove(&path_owned);
|
||||
// Remove from tracked set so hotplug can re-attach if reconnected.
|
||||
tracked_for_cleanup.lock().await.remove(&path_owned);
|
||||
});
|
||||
|
||||
drop(tracked_map);
|
||||
|
||||
// Register with the supervisor. This await is brief — it just locks
|
||||
// the supervisor inner Vec and pushes — and happens outside the
|
||||
// tracked-map lock.
|
||||
supervisor.register("device-listener", listener_handle).await;
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
@@ -427,4 +570,11 @@ mod tests {
|
||||
keys.insert(Key::KEY_R);
|
||||
assert!(!device_supports_combo(Some(&keys), &combo_for(KEY_D)));
|
||||
}
|
||||
|
||||
// TODO(test): Race-extra (TOCTOU on `tracked`) is hard to exercise
|
||||
// without real /dev/input/event* devices + the udev attach race.
|
||||
// The new insert-before-spawn + supervisor-owned-handle design
|
||||
// closes the window by construction; a deterministic test would need
|
||||
// to fake the evdev::Device::open path which the crate doesn't
|
||||
// currently expose. See atomiser finding "Race-extra" for context.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user