2026-04-12 18:41:13 -03:00
|
|
|
//! Integration smoke coverage for server runtime helpers.
|
|
|
|
|
//!
|
|
|
|
|
//! Scope: exercise public runtime helpers that should be stable even on hosts
|
|
|
|
|
//! without full gadget/audio plumbing.
|
|
|
|
|
//! Targets: `server/src/audio.rs`, `server/src/gadget.rs`,
|
|
|
|
|
//! `server/src/runtime_support.rs`.
|
|
|
|
|
//! Why: these contracts provide broad safety coverage around startup/recovery
|
|
|
|
|
//! code paths that are otherwise hard to hit from unit-only tests.
|
|
|
|
|
|
2026-04-12 21:14:33 -03:00
|
|
|
use lesavka_server::audio::{self, ClipTap};
|
2026-04-12 18:41:13 -03:00
|
|
|
use lesavka_server::gadget::UsbGadget;
|
|
|
|
|
use lesavka_server::runtime_support;
|
|
|
|
|
use serial_test::serial;
|
|
|
|
|
use std::collections::HashSet;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
use temp_env::with_var;
|
|
|
|
|
use tempfile::NamedTempFile;
|
|
|
|
|
use tokio::runtime::Runtime;
|
|
|
|
|
use tokio::sync::Mutex;
|
|
|
|
|
|
|
|
|
|
fn tmp_files_with_prefix(prefix: &str) -> HashSet<PathBuf> {
|
|
|
|
|
std::fs::read_dir("/tmp")
|
|
|
|
|
.ok()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.flat_map(|iter| iter.flatten())
|
|
|
|
|
.map(|entry| entry.path())
|
|
|
|
|
.filter(|path| {
|
|
|
|
|
path.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.map(|name| name.starts_with(prefix))
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn clip_tap_writes_rotating_file_when_period_elapses() {
|
|
|
|
|
let tag = "lesavka-cliptap-contract";
|
|
|
|
|
let prefix = format!("{tag}-");
|
|
|
|
|
let before = tmp_files_with_prefix(&prefix);
|
|
|
|
|
|
|
|
|
|
let mut tap = ClipTap::new(tag, Duration::from_millis(5));
|
|
|
|
|
tap.feed(b"abc");
|
|
|
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
|
|
|
tap.feed(b"def");
|
|
|
|
|
tap.flush();
|
|
|
|
|
|
|
|
|
|
let after = tmp_files_with_prefix(&prefix);
|
|
|
|
|
let created: Vec<PathBuf> = after.difference(&before).cloned().collect();
|
|
|
|
|
assert!(
|
|
|
|
|
!created.is_empty(),
|
|
|
|
|
"expected at least one clip file to be written"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for path in created {
|
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 21:14:33 -03:00
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn clip_tap_drop_flushes_pending_audio() {
|
|
|
|
|
let tag = "lesavka-cliptap-drop-contract";
|
|
|
|
|
let prefix = format!("{tag}-");
|
|
|
|
|
let before = tmp_files_with_prefix(&prefix);
|
|
|
|
|
|
|
|
|
|
let mut tap = ClipTap::new(tag, Duration::from_secs(60));
|
|
|
|
|
tap.feed(b"pending-bytes");
|
|
|
|
|
drop(tap);
|
|
|
|
|
|
|
|
|
|
let after = tmp_files_with_prefix(&prefix);
|
|
|
|
|
let created: Vec<PathBuf> = after.difference(&before).cloned().collect();
|
|
|
|
|
assert!(
|
|
|
|
|
!created.is_empty(),
|
|
|
|
|
"expected drop() to flush buffered clip bytes"
|
|
|
|
|
);
|
|
|
|
|
for path in created {
|
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn clip_tap_flush_is_noop_when_buffer_is_empty() {
|
|
|
|
|
let tag = "lesavka-cliptap-empty-contract";
|
|
|
|
|
let prefix = format!("{tag}-");
|
|
|
|
|
let before = tmp_files_with_prefix(&prefix);
|
|
|
|
|
|
|
|
|
|
let mut tap = ClipTap::new(tag, Duration::from_secs(60));
|
|
|
|
|
tap.flush();
|
|
|
|
|
drop(tap);
|
|
|
|
|
|
|
|
|
|
let after = tmp_files_with_prefix(&prefix);
|
|
|
|
|
let created: Vec<PathBuf> = after.difference(&before).cloned().collect();
|
|
|
|
|
assert!(
|
|
|
|
|
created.is_empty(),
|
|
|
|
|
"empty flush/drop should not create clip artifacts"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 18:41:13 -03:00
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn gadget_queries_handle_missing_controller_paths() {
|
|
|
|
|
let _gadget = UsbGadget::new("lesavka");
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
UsbGadget::state("definitely-missing-udc").is_err(),
|
|
|
|
|
"state() should fail for a non-existent UDC controller"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
match UsbGadget::find_controller() {
|
|
|
|
|
Ok(name) => assert!(!name.trim().is_empty()),
|
|
|
|
|
Err(err) => assert!(!err.to_string().trim().is_empty()),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
UsbGadget::wait_state_any("definitely-missing-udc", 0).is_err(),
|
|
|
|
|
"wait_state_any() should fail quickly for a non-existent controller"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn runtime_open_voice_with_retry_stays_bounded_for_missing_device() {
|
|
|
|
|
with_var("LESAVKA_MIC_INIT_ATTEMPTS", Some("1"), || {
|
|
|
|
|
with_var("LESAVKA_MIC_INIT_DELAY_MS", Some("1"), || {
|
|
|
|
|
let rt = Runtime::new().expect("create runtime");
|
|
|
|
|
let outcome = rt.block_on(async {
|
|
|
|
|
tokio::time::timeout(
|
|
|
|
|
Duration::from_secs(3),
|
|
|
|
|
runtime_support::open_voice_with_retry("hw:DefinitelyMissing,0"),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
});
|
|
|
|
|
match outcome {
|
|
|
|
|
Ok(Ok(mut voice)) => {
|
|
|
|
|
voice.finish();
|
|
|
|
|
}
|
|
|
|
|
Ok(Err(err)) => {
|
|
|
|
|
assert!(!err.to_string().trim().is_empty());
|
|
|
|
|
}
|
|
|
|
|
Err(_) => panic!("open_voice_with_retry timed out"),
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 21:14:33 -03:00
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn runtime_ear_handles_malformed_alsa_source_without_hanging() {
|
|
|
|
|
let rt = Runtime::new().expect("create runtime");
|
|
|
|
|
let outcome = rt.block_on(async {
|
|
|
|
|
tokio::time::timeout(
|
|
|
|
|
Duration::from_secs(3),
|
|
|
|
|
audio::ear("hw:UAC2Gadget,0\" ! malformed-pipeline", 0),
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
match outcome {
|
|
|
|
|
Ok(Ok(stream)) => drop(stream),
|
|
|
|
|
Ok(Err(err)) => assert!(!err.to_string().trim().is_empty()),
|
|
|
|
|
Err(_) => panic!("audio::ear timed out for malformed ALSA source"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 18:41:13 -03:00
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn runtime_recover_hid_ignores_non_transport_errors() {
|
|
|
|
|
let rt = Runtime::new().expect("create runtime");
|
|
|
|
|
rt.block_on(async {
|
|
|
|
|
let kb_tmp = NamedTempFile::new().expect("temp keyboard file");
|
|
|
|
|
let ms_tmp = NamedTempFile::new().expect("temp mouse file");
|
|
|
|
|
|
|
|
|
|
let kb = tokio::fs::OpenOptions::new()
|
|
|
|
|
.write(true)
|
|
|
|
|
.open(kb_tmp.path())
|
|
|
|
|
.await
|
|
|
|
|
.expect("open temp kb");
|
|
|
|
|
let ms = tokio::fs::OpenOptions::new()
|
|
|
|
|
.write(true)
|
|
|
|
|
.open(ms_tmp.path())
|
|
|
|
|
.await
|
|
|
|
|
.expect("open temp ms");
|
|
|
|
|
|
|
|
|
|
let kb = Arc::new(Mutex::new(kb));
|
|
|
|
|
let ms = Arc::new(Mutex::new(ms));
|
|
|
|
|
let did_cycle = Arc::new(AtomicBool::new(false));
|
|
|
|
|
let err = std::io::Error::from_raw_os_error(libc::EAGAIN);
|
|
|
|
|
|
|
|
|
|
runtime_support::recover_hid_if_needed(
|
|
|
|
|
&err,
|
|
|
|
|
UsbGadget::new("lesavka"),
|
|
|
|
|
kb,
|
|
|
|
|
ms,
|
|
|
|
|
did_cycle.clone(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
!did_cycle.load(Ordering::SeqCst),
|
|
|
|
|
"non-transport errors should not trigger gadget recovery"
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-13 02:52:32 -03:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn runtime_recover_hid_short_circuits_when_cycle_already_in_progress() {
|
|
|
|
|
let rt = Runtime::new().expect("create runtime");
|
|
|
|
|
rt.block_on(async {
|
|
|
|
|
let kb_tmp = NamedTempFile::new().expect("temp keyboard file");
|
|
|
|
|
let ms_tmp = NamedTempFile::new().expect("temp mouse file");
|
|
|
|
|
|
|
|
|
|
let kb = tokio::fs::OpenOptions::new()
|
|
|
|
|
.write(true)
|
|
|
|
|
.open(kb_tmp.path())
|
|
|
|
|
.await
|
|
|
|
|
.expect("open temp kb");
|
|
|
|
|
let ms = tokio::fs::OpenOptions::new()
|
|
|
|
|
.write(true)
|
|
|
|
|
.open(ms_tmp.path())
|
|
|
|
|
.await
|
|
|
|
|
.expect("open temp ms");
|
|
|
|
|
|
|
|
|
|
let kb = Arc::new(Mutex::new(kb));
|
|
|
|
|
let ms = Arc::new(Mutex::new(ms));
|
|
|
|
|
let did_cycle = Arc::new(AtomicBool::new(true));
|
|
|
|
|
let err = std::io::Error::from_raw_os_error(libc::EPIPE);
|
|
|
|
|
|
|
|
|
|
runtime_support::recover_hid_if_needed(
|
|
|
|
|
&err,
|
|
|
|
|
UsbGadget::new("lesavka"),
|
|
|
|
|
kb,
|
|
|
|
|
ms,
|
|
|
|
|
did_cycle.clone(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
did_cycle.load(Ordering::SeqCst),
|
|
|
|
|
"existing cycle lock should short-circuit recovery"
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn runtime_recover_hid_resets_cycle_flag_after_async_recovery_path() {
|
|
|
|
|
with_var("LESAVKA_ALLOW_GADGET_CYCLE", None::<&str>, || {
|
|
|
|
|
let rt = Runtime::new().expect("create runtime");
|
|
|
|
|
rt.block_on(async {
|
|
|
|
|
let kb_tmp = NamedTempFile::new().expect("temp keyboard file");
|
|
|
|
|
let ms_tmp = NamedTempFile::new().expect("temp mouse file");
|
|
|
|
|
|
|
|
|
|
let kb = tokio::fs::OpenOptions::new()
|
|
|
|
|
.write(true)
|
|
|
|
|
.open(kb_tmp.path())
|
|
|
|
|
.await
|
|
|
|
|
.expect("open temp kb");
|
|
|
|
|
let ms = tokio::fs::OpenOptions::new()
|
|
|
|
|
.write(true)
|
|
|
|
|
.open(ms_tmp.path())
|
|
|
|
|
.await
|
|
|
|
|
.expect("open temp ms");
|
|
|
|
|
|
|
|
|
|
let kb = Arc::new(Mutex::new(kb));
|
|
|
|
|
let ms = Arc::new(Mutex::new(ms));
|
|
|
|
|
let did_cycle = Arc::new(AtomicBool::new(false));
|
|
|
|
|
let err = std::io::Error::from_raw_os_error(libc::EPIPE);
|
|
|
|
|
|
|
|
|
|
runtime_support::recover_hid_if_needed(
|
|
|
|
|
&err,
|
|
|
|
|
UsbGadget::new("lesavka"),
|
|
|
|
|
kb.clone(),
|
|
|
|
|
ms.clone(),
|
|
|
|
|
did_cycle.clone(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
did_cycle.load(Ordering::SeqCst),
|
|
|
|
|
"transport error should acquire recovery lock"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
tokio::time::sleep(Duration::from_millis(2_300)).await;
|
|
|
|
|
assert!(
|
|
|
|
|
!did_cycle.load(Ordering::SeqCst),
|
|
|
|
|
"recovery task should release lock after cooldown"
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn runtime_recover_hid_attempts_cycle_when_enabled() {
|
|
|
|
|
with_var("LESAVKA_ALLOW_GADGET_CYCLE", Some("1"), || {
|
|
|
|
|
let rt = Runtime::new().expect("create runtime");
|
|
|
|
|
rt.block_on(async {
|
|
|
|
|
let kb_tmp = NamedTempFile::new().expect("temp keyboard file");
|
|
|
|
|
let ms_tmp = NamedTempFile::new().expect("temp mouse file");
|
|
|
|
|
|
|
|
|
|
let kb = tokio::fs::OpenOptions::new()
|
|
|
|
|
.write(true)
|
|
|
|
|
.open(kb_tmp.path())
|
|
|
|
|
.await
|
|
|
|
|
.expect("open temp kb");
|
|
|
|
|
let ms = tokio::fs::OpenOptions::new()
|
|
|
|
|
.write(true)
|
|
|
|
|
.open(ms_tmp.path())
|
|
|
|
|
.await
|
|
|
|
|
.expect("open temp ms");
|
|
|
|
|
|
|
|
|
|
let kb = Arc::new(Mutex::new(kb));
|
|
|
|
|
let ms = Arc::new(Mutex::new(ms));
|
|
|
|
|
let did_cycle = Arc::new(AtomicBool::new(false));
|
|
|
|
|
let err = std::io::Error::from_raw_os_error(libc::EPIPE);
|
|
|
|
|
|
|
|
|
|
runtime_support::recover_hid_if_needed(
|
|
|
|
|
&err,
|
|
|
|
|
UsbGadget::new("lesavka"),
|
|
|
|
|
kb.clone(),
|
|
|
|
|
ms.clone(),
|
|
|
|
|
did_cycle.clone(),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
tokio::time::sleep(Duration::from_millis(2_300)).await;
|
|
|
|
|
assert!(
|
|
|
|
|
!did_cycle.load(Ordering::SeqCst),
|
|
|
|
|
"cycle-enabled recovery should eventually clear lock"
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|