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:
193
crates/hotkey/tests/listener_lifecycle.rs
Normal file
193
crates/hotkey/tests/listener_lifecycle.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
//! Regression tests for the three concurrency leaks the code-atomiser
|
||||
//! flagged in the hotkey listener:
|
||||
//!
|
||||
//! - **Race-1** — orphaned per-device listener tasks after `stop()`.
|
||||
//! Coverage: `listener_stop_drops_internal_senders` — exercises the
|
||||
//! only side-effect we can observe through the public API. After
|
||||
//! `stop()` every internal device-listener task must drop its
|
||||
//! `mpsc::Sender<HotkeyEvent>` clone, which we detect by asserting
|
||||
//! that the receiving end's `recv()` returns `None`.
|
||||
//!
|
||||
//! - **Race-2** — leaked forwarder task on reconfigure. Coverage:
|
||||
//! `reconfigure_does_not_leak_forwarder` — simulates the exact pattern
|
||||
//! the Tauri command layer uses (listener + forwarder pair), confirms
|
||||
//! that after a reconfigure the OLD forwarder has joined and only the
|
||||
//! new one is consuming events.
|
||||
//!
|
||||
//! - **Race-extra (TOCTOU)** — see `// TODO(test):` in
|
||||
//! `crates/hotkey/src/linux.rs`. Exercising the TOCTOU window
|
||||
//! deterministically requires faking the evdev::Device::open path,
|
||||
//! which the crate does not currently expose. The fix is closed by
|
||||
//! construction (insert-before-spawn under one mutex hold + supervisor
|
||||
//! ownership of every spawn handle).
|
||||
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use lumotia_hotkey::{EvdevHotkeyListener, HotkeyCombo, HotkeyEvent};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
fn dummy_combo() -> HotkeyCombo {
|
||||
// KEY_R = 19, the default Lumotia hotkey. No device on the CI box is
|
||||
// expected to match for actual key events — these tests exercise the
|
||||
// lifecycle, not the event-firing path.
|
||||
HotkeyCombo {
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
super_key: false,
|
||||
key_code: 19,
|
||||
label: "Ctrl+Shift+R".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Race-1 regression. After `stop()`, every device-listener and watcher
|
||||
/// task spawned by the listener must exit and drop its `event_tx` clone.
|
||||
/// We detect that by holding the receiving end and observing the
|
||||
/// channel-closed signal (`recv()` returning `None`).
|
||||
///
|
||||
/// If the bug regressed (listeners orphaned), `recv()` would block
|
||||
/// indefinitely because the leaked tasks still hold sender clones, and
|
||||
/// the test would time out.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn listener_stop_drops_internal_senders() {
|
||||
let (event_tx, mut event_rx) = mpsc::channel::<HotkeyEvent>(64);
|
||||
|
||||
let listener = EvdevHotkeyListener::start(dummy_combo(), event_tx).await;
|
||||
|
||||
// Let the supervisor spin up its tasks. The scanner+hotplug watcher
|
||||
// tasks subscribe to broadcast shutdown during construction, so the
|
||||
// scheduling order doesn't really matter, but we yield once for
|
||||
// robustness.
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
let stop_start = Instant::now();
|
||||
listener.stop().await;
|
||||
let stop_elapsed = stop_start.elapsed();
|
||||
|
||||
// Stop must itself be bounded. The supervisor's per-task timeout is
|
||||
// 2 s, and we have at most a handful of internal tasks (scanner,
|
||||
// hotplug, plus however many real /dev/input devices the test
|
||||
// sandbox exposes — usually zero on CI). Cap total stop time at
|
||||
// 10 s to give us a clear failure rather than a hung CI runner.
|
||||
assert!(
|
||||
stop_elapsed < Duration::from_secs(10),
|
||||
"EvdevHotkeyListener::stop() should bound on supervisor timeout; took {stop_elapsed:?}"
|
||||
);
|
||||
|
||||
// After stop, every internal sender clone must be dropped, which
|
||||
// closes the channel for the receiver. `recv()` returns None on a
|
||||
// closed-and-drained channel. We wrap in a timeout so a regressed
|
||||
// implementation (leaked listener tasks holding sender clones)
|
||||
// surfaces as a clean assertion failure rather than a hung test.
|
||||
let recv_result = tokio::time::timeout(Duration::from_secs(5), event_rx.recv()).await;
|
||||
|
||||
match recv_result {
|
||||
Ok(None) => {
|
||||
// Pass — channel closed, no sender clones leaked.
|
||||
}
|
||||
Ok(Some(ev)) => panic!(
|
||||
"received hotkey event {ev:?} after stop() — listener tasks should have exited \
|
||||
and dropped their senders, indicating Race-1 regressed"
|
||||
),
|
||||
Err(_) => panic!(
|
||||
"timed out waiting for event_rx to close after stop() — internal sender clones \
|
||||
were not dropped, indicating Race-1 regressed (orphaned listener tasks)"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Race-2 regression. Simulates the Tauri command layer's
|
||||
/// listener+forwarder pair. After a reconfigure (stop old, start new),
|
||||
/// only the NEW forwarder must be alive — the previous implementation
|
||||
/// leaked one forwarder per reconfigure.
|
||||
///
|
||||
/// We assert leak-freedom by:
|
||||
/// 1. Holding a JoinHandle to each forwarder we spawn.
|
||||
/// 2. After the reconfigure, asserting the old forwarder's JoinHandle
|
||||
/// is finished within a bounded timeout.
|
||||
///
|
||||
/// The new forwarder's JoinHandle must NOT be finished (it's still
|
||||
/// receiving from the new listener).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reconfigure_does_not_leak_forwarder() {
|
||||
let received_first = Arc::new(AtomicUsize::new(0));
|
||||
let received_second = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
// ---- First listener + forwarder ----
|
||||
let (event_tx_1, mut event_rx_1) = mpsc::channel::<HotkeyEvent>(64);
|
||||
let listener_1 = EvdevHotkeyListener::start(dummy_combo(), event_tx_1).await;
|
||||
let counter_1 = received_first.clone();
|
||||
let forwarder_1 = tokio::spawn(async move {
|
||||
while let Some(_event) = event_rx_1.recv().await {
|
||||
counter_1.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
// Returns when the channel closes (all senders dropped). That's
|
||||
// the only clean way for the forwarder to exit, and it must
|
||||
// happen on reconfigure for the leak to be fixed.
|
||||
});
|
||||
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
// ---- Reconfigure: stop old, start new ----
|
||||
// This mirrors `start_evdev_hotkey` in src-tauri/src/commands/hotkey.rs
|
||||
// after the fix: stop old listener (which drains every internal task
|
||||
// via the supervisor) THEN join the old forwarder (which exits when
|
||||
// all sender clones drop) BEFORE installing the new pair.
|
||||
listener_1.stop().await;
|
||||
|
||||
// Old forwarder must finish in bounded time. If Race-2 regressed
|
||||
// (orphaned listener tasks still holding sender clones), the
|
||||
// forwarder would never see `None` from recv() and this timeout
|
||||
// would fire.
|
||||
let join_result =
|
||||
tokio::time::timeout(Duration::from_secs(5), forwarder_1).await;
|
||||
assert!(
|
||||
join_result.is_ok(),
|
||||
"old forwarder did not join after listener.stop() — Race-2 regressed: \
|
||||
orphaned listener tasks are still holding event_tx clones"
|
||||
);
|
||||
// Verify the inner result (forwarder didn't panic).
|
||||
join_result.unwrap().expect("old forwarder panicked");
|
||||
|
||||
// ---- Second listener + forwarder ----
|
||||
let (event_tx_2, mut event_rx_2) = mpsc::channel::<HotkeyEvent>(64);
|
||||
let listener_2 = EvdevHotkeyListener::start(dummy_combo(), event_tx_2).await;
|
||||
let counter_2 = received_second.clone();
|
||||
let forwarder_2 = tokio::spawn(async move {
|
||||
while let Some(_event) = event_rx_2.recv().await {
|
||||
counter_2.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
// Sanity check: the new forwarder is still running (not yet
|
||||
// joined). `is_finished()` returns true only when the task has
|
||||
// completed.
|
||||
assert!(
|
||||
!forwarder_2.is_finished(),
|
||||
"new forwarder must still be running after reconfigure — otherwise \
|
||||
the new listener's senders were dropped prematurely"
|
||||
);
|
||||
|
||||
// ---- Cleanup ----
|
||||
listener_2.stop().await;
|
||||
let cleanup_join =
|
||||
tokio::time::timeout(Duration::from_secs(5), forwarder_2).await;
|
||||
assert!(
|
||||
cleanup_join.is_ok(),
|
||||
"second forwarder also failed to drain after stop()"
|
||||
);
|
||||
|
||||
// We don't actually assert on the counters — these tests run without
|
||||
// a matching evdev device, so no Pressed/Released events fire. The
|
||||
// leak detection is in the JoinHandle behaviour above, not the event
|
||||
// count. The counters exist so the test compiles as a real
|
||||
// forwarder pattern matching what commands::hotkey does in
|
||||
// production.
|
||||
let _ = (received_first.load(Ordering::SeqCst), received_second.load(Ordering::SeqCst));
|
||||
}
|
||||
Reference in New Issue
Block a user