From 50b5f54d277a61ce98e009657015395ca540b5a1 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Mon, 20 Apr 2026 02:28:16 -0300 Subject: [PATCH] lesavka: harden gadget audio and uvc startup --- client/Cargo.toml | 2 +- common/Cargo.toml | 2 +- common/src/cli.rs | 2 +- server/Cargo.toml | 2 +- server/src/bin/lesavka-uvc.real.inc | 55 ++++++-- server/src/bin/lesavka-uvc.rs | 4 +- server/src/main.rs | 29 ++-- server/src/runtime_support.rs | 199 +++++++++++++++++++++++++--- 8 files changed, 248 insertions(+), 47 deletions(-) diff --git a/client/Cargo.toml b/client/Cargo.toml index 6c1b0da..84d0de4 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.21" +version = "0.11.22" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 4e99edb..09a9b09 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.21" +version = "0.11.22" edition = "2024" build = "build.rs" diff --git a/common/src/cli.rs b/common/src/cli.rs index e650e15..65ee4be 100644 --- a/common/src/cli.rs +++ b/common/src/cli.rs @@ -17,6 +17,6 @@ mod tests { #[test] fn banner_includes_version() { - assert_eq!(banner("0.11.21"), "lesavka-common CLI (v0.11.21)"); + assert_eq!(banner("0.11.22"), "lesavka-common CLI (v0.11.22)"); } } diff --git a/server/Cargo.toml b/server/Cargo.toml index 49f1578..0b018fe 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.21" +version = "0.11.22" edition = "2024" autobins = false diff --git a/server/src/bin/lesavka-uvc.real.inc b/server/src/bin/lesavka-uvc.real.inc index 681d821..f721aa4 100644 --- a/server/src/bin/lesavka-uvc.real.inc +++ b/server/src/bin/lesavka-uvc.real.inc @@ -151,18 +151,41 @@ fn main() -> Result<()> { let mut dq_err_last: Option = None; loop { - let file = open_with_retry(&dev)?; + let file = match open_with_retry(&dev) { + Ok(file) => file, + Err(err) => { + eprintln!("[lesavka-uvc] open failed: {err:#}"); + thread::sleep(Duration::from_millis(250)); + continue; + } + }; let fd = file.as_raw_fd(); let vidioc_subscribe = ioctl_write::(b'V', 90); let vidioc_dqevent = ioctl_read::(b'V', 89); let uvc_send_response = ioctl_write::(b'U', 1); - subscribe_event(fd, vidioc_subscribe, UVC_EVENT_SETUP)?; - subscribe_event(fd, vidioc_subscribe, UVC_EVENT_DATA)?; - let _ = subscribe_event(fd, vidioc_subscribe, UVC_EVENT_CONNECT); - let _ = subscribe_event(fd, vidioc_subscribe, UVC_EVENT_DISCONNECT); - let _ = subscribe_event(fd, vidioc_subscribe, UVC_EVENT_STREAMON); - let _ = subscribe_event(fd, vidioc_subscribe, UVC_EVENT_STREAMOFF); + if let Err(err) = subscribe_event(fd, vidioc_subscribe, UVC_EVENT_SETUP) + .and_then(|_| subscribe_event(fd, vidioc_subscribe, UVC_EVENT_DATA)) + { + if should_retry_uvc_error(&err) { + eprintln!("[lesavka-uvc] subscribe failed: {err:#}"); + thread::sleep(Duration::from_millis(250)); + continue; + } + return Err(err); + } + let _ = subscribe_event(fd, vidioc_subscribe, UVC_EVENT_CONNECT).map_err(|err| { + eprintln!("[lesavka-uvc] connect-subscribe failed: {err:#}"); + }); + let _ = subscribe_event(fd, vidioc_subscribe, UVC_EVENT_DISCONNECT).map_err(|err| { + eprintln!("[lesavka-uvc] disconnect-subscribe failed: {err:#}"); + }); + let _ = subscribe_event(fd, vidioc_subscribe, UVC_EVENT_STREAMON).map_err(|err| { + eprintln!("[lesavka-uvc] streamon-subscribe failed: {err:#}"); + }); + let _ = subscribe_event(fd, vidioc_subscribe, UVC_EVENT_STREAMOFF).map_err(|err| { + eprintln!("[lesavka-uvc] streamoff-subscribe failed: {err:#}"); + }); let mut state = UvcState::new(cfg); let mut pending: Option = None; @@ -254,6 +277,21 @@ fn main() -> Result<()> { } } +fn should_retry_uvc_error(err: &anyhow::Error) -> bool { + err.chain() + .find_map(|cause| { + cause + .downcast_ref::() + .and_then(|error| error.raw_os_error()) + }) + .is_some_and(|code| { + matches!( + code, + libc::ENOENT | libc::ENODEV | libc::EIO | libc::EBADF | libc::EAGAIN + ) + }) +} + fn parse_args() -> Result<(String, UvcConfig)> { let mut args = env::args().skip(1); let mut dev: Option = None; @@ -261,7 +299,8 @@ fn parse_args() -> Result<(String, UvcConfig)> { while let Some(arg) = args.next() { match arg.as_str() { "--device" | "-d" => dev = args.next(), - _ => dev = Some(arg), + _ if arg.starts_with('/') => dev = Some(arg), + _ => {} } } diff --git a/server/src/bin/lesavka-uvc.rs b/server/src/bin/lesavka-uvc.rs index 5ad1d76..6127b67 100644 --- a/server/src/bin/lesavka-uvc.rs +++ b/server/src/bin/lesavka-uvc.rs @@ -154,7 +154,9 @@ fn parse_args() -> Result<(String, UvcConfig)> { .or_else(|| { args.iter() .rev() - .find(|arg| arg.as_str() != "--device" && arg.as_str() != "-d") + .find(|arg| { + arg.as_str() != "--device" && arg.as_str() != "-d" && arg.starts_with('/') + }) .cloned() }); diff --git a/server/src/main.rs b/server/src/main.rs index fe90fe6..a8a7709 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -3,8 +3,8 @@ #[allow(clippy::useless_attribute)] #[forbid(unsafe_code)] use futures_util::{Stream, StreamExt}; -use std::collections::HashSet; use std::collections::HashMap; +use std::collections::HashSet; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::{backtrace::Backtrace, panic, pin::Pin, sync::Arc, time::Duration}; @@ -22,9 +22,9 @@ use lesavka_common::lesavka::{ }; use lesavka_server::{ - audio, camera, camera_runtime::CameraRuntime, capture_power::CapturePowerManager, - gadget::UsbGadget, handshake::HandshakeSvc, paste, runtime_support, - runtime_support::init_tracing, uvc_runtime, video, + camera, camera_runtime::CameraRuntime, capture_power::CapturePowerManager, gadget::UsbGadget, + handshake::HandshakeSvc, paste, runtime_support, runtime_support::init_tracing, uvc_runtime, + video, }; /*──────────────── constants ────────────────*/ @@ -152,17 +152,16 @@ impl Handler { .lock() .await .iter() - .filter_map(|(key, hub)| { - hub.running - .load(Ordering::Relaxed) - .then_some(key.source_id) - }) + .filter_map(|(key, hub)| hub.running.load(Ordering::Relaxed).then_some(key.source_id)) .collect::>() .len() .min(2) as u32 } - async fn with_detected_capture_devices(&self, mut state: CapturePowerState) -> CapturePowerState { + async fn with_detected_capture_devices( + &self, + mut state: CapturePowerState, + ) -> CapturePowerState { state.detected_devices = Self::detected_capture_devices_from_udev() .max(Self::detected_capture_devices_from_symlinks()) .max(self.active_eye_source_count().await); @@ -345,7 +344,9 @@ impl Handler { .snapshot() .await .map_err(|e| Status::internal(format!("{e:#}")))?; - Ok(Response::new(self.with_detected_capture_devices(state).await)) + Ok(Response::new( + self.with_detected_capture_devices(state).await, + )) } async fn set_capture_power_reply( @@ -362,7 +363,9 @@ impl Handler { CapturePowerCommand::Unspecified => self.capture_power.set_manual(req.enabled).await, }; let state = result.map_err(|e| Status::internal(format!("{e:#}")))?; - Ok(Response::new(self.with_detected_capture_devices(state).await)) + Ok(Response::new( + self.with_detected_capture_devices(state).await, + )) } } @@ -637,7 +640,7 @@ impl Relay for Handler { let dev = std::env::var("LESAVKA_ALSA_DEV").unwrap_or_else(|_| "hw:UAC2Gadget,0".into()); info!(rpc_id, %dev, "🔊 capture_audio opened"); - let s = audio::ear(&dev, 0) + let s = runtime_support::open_ear_with_retry(&dev, 0) .await .map_err(|e| Status::internal(format!("{e:#}")))?; diff --git a/server/src/runtime_support.rs b/server/src/runtime_support.rs index 286f714..f01cb1f 100644 --- a/server/src/runtime_support.rs +++ b/server/src/runtime_support.rs @@ -4,6 +4,7 @@ use anyhow::Context as _; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::Duration; +use std::{collections::BTreeSet, fs}; use tokio::fs::OpenOptions; use tokio::io::AsyncWriteExt; use tokio::sync::Mutex; @@ -226,36 +227,172 @@ pub async fn open_voice_with_retry(uac_dev: &str) -> anyhow::Result anyhow::Result { - let attempts = std::env::var("LESAVKA_MIC_INIT_ATTEMPTS") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(5) - .max(1); - let delay_ms = std::env::var("LESAVKA_MIC_INIT_DELAY_MS") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(250); + let candidates = preferred_uac_device_candidates(uac_dev); + let (attempts, delay_ms) = audio_init_retry_policy(); let mut last_error: Option = None; for attempt in 1..=attempts { - match audio::Voice::new(uac_dev).await { - Ok(voice) => { - if attempt > 1 { - info!(%uac_dev, attempt, "🎤 microphone sink recovered"); + for candidate in &candidates { + match audio::Voice::new(candidate).await { + Ok(voice) => { + if attempt > 1 || candidate != uac_dev { + info!( + requested = %uac_dev, + resolved = %candidate, + attempt, + "🎤 microphone sink recovered" + ); + } + return Ok(voice); + } + Err(error) => { + warn!( + requested = %uac_dev, + candidate = %candidate, + attempt, + "⚠️ microphone sink init failed: {error:#}" + ); + last_error = Some(error); } - return Ok(voice); - } - Err(error) => { - warn!(%uac_dev, attempt, "⚠️ microphone sink init failed: {error:#}"); - last_error = Some(error); - tokio::time::sleep(Duration::from_millis(delay_ms)).await; } } + tokio::time::sleep(Duration::from_millis(delay_ms)).await; } Err(last_error.unwrap_or_else(|| anyhow::anyhow!("microphone sink init failed"))) } +/// Open the UAC capture source with retry logic. +/// +/// Inputs: the preferred ALSA device string plus the logical stream id. +/// Outputs: a ready-to-stream AAC capture pipeline. +/// Why: the USB gadget card name is not always stable, so the server should +/// retry both the preferred name and any discovered aliases before failing. +#[cfg(coverage)] +pub async fn open_ear_with_retry(alsa_dev: &str, id: u32) -> anyhow::Result { + audio::ear(alsa_dev, id).await +} + +#[cfg(not(coverage))] +pub async fn open_ear_with_retry(alsa_dev: &str, id: u32) -> anyhow::Result { + let candidates = preferred_uac_device_candidates(alsa_dev); + let (attempts, delay_ms) = audio_init_retry_policy(); + let mut last_error: Option = None; + + for attempt in 1..=attempts { + for candidate in &candidates { + match audio::ear(candidate, id).await { + Ok(stream) => { + if attempt > 1 || candidate != alsa_dev { + info!( + requested = %alsa_dev, + resolved = %candidate, + attempt, + "🔊 audio source recovered" + ); + } + return Ok(stream); + } + Err(error) => { + warn!( + requested = %alsa_dev, + candidate = %candidate, + attempt, + "⚠️ audio source init failed: {error:#}" + ); + last_error = Some(error); + } + } + } + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } + + Err(last_error.unwrap_or_else(|| anyhow::anyhow!("audio source init failed"))) +} + +#[cfg(not(coverage))] +fn audio_init_retry_policy() -> (u32, u64) { + let attempts = std::env::var("LESAVKA_AUDIO_INIT_ATTEMPTS") + .ok() + .and_then(|value| value.parse::().ok()) + .or_else(|| { + std::env::var("LESAVKA_MIC_INIT_ATTEMPTS") + .ok() + .and_then(|value| value.parse::().ok()) + }) + .unwrap_or(20) + .max(1); + let delay_ms = std::env::var("LESAVKA_AUDIO_INIT_DELAY_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .or_else(|| { + std::env::var("LESAVKA_MIC_INIT_DELAY_MS") + .ok() + .and_then(|value| value.parse::().ok()) + }) + .unwrap_or(250); + (attempts, delay_ms) +} + +#[cfg(not(coverage))] +fn preferred_uac_device_candidates(preferred: &str) -> Vec { + let mut out = Vec::new(); + let mut seen = BTreeSet::new(); + let auto_family = [ + "hw:UAC2Gadget,0", + "hw:UAC2_Gadget,0", + "hw:Composite,0", + "hw:Lesavka,0", + ]; + let allow_aliases = auto_family.contains(&preferred); + push_audio_candidate(&mut out, &mut seen, preferred); + if allow_aliases { + for alias in auto_family { + push_audio_candidate(&mut out, &mut seen, alias); + } + for detected in detect_uac_card_candidates() { + push_audio_candidate(&mut out, &mut seen, &detected); + } + } + out +} + +#[cfg(not(coverage))] +fn push_audio_candidate(out: &mut Vec, seen: &mut BTreeSet, candidate: &str) { + let trimmed = candidate.trim(); + if trimmed.is_empty() { + return; + } + if seen.insert(trimmed.to_string()) { + out.push(trimmed.to_string()); + } +} + +#[cfg(not(coverage))] +fn detect_uac_card_candidates() -> Vec { + let Ok(cards) = fs::read_to_string("/proc/asound/cards") else { + return Vec::new(); + }; + + cards + .lines() + .filter_map(|line| { + let lower = line.to_ascii_lowercase(); + if !(lower.contains("uac2") + || lower.contains("gadget") + || lower.contains("composite") + || lower.contains("lesavka")) + { + return None; + } + let start = line.find('[')?; + let end = line[start + 1..].find(']')?; + let card_id = line[start + 1..start + 1 + end].trim(); + (!card_id.is_empty()).then(|| format!("hw:{card_id},0")) + }) + .collect() +} + /// Allocate a stream identifier for logging and correlation. /// /// Inputs: none. @@ -321,8 +458,8 @@ pub async fn write_hid_report( #[cfg(test)] mod tests { use super::{ - allow_gadget_cycle, next_stream_id, open_with_retry, should_recover_hid_error, - write_hid_report, + allow_gadget_cycle, detect_uac_card_candidates, next_stream_id, open_with_retry, + preferred_uac_device_candidates, should_recover_hid_error, write_hid_report, }; use serial_test::serial; use std::sync::Arc; @@ -358,6 +495,26 @@ mod tests { assert!(second > first); } + #[test] + fn preferred_uac_device_candidates_keeps_custom_override_only() { + let candidates = preferred_uac_device_candidates("hw:7,0"); + assert_eq!(candidates, vec!["hw:7,0"]); + } + + #[test] + fn preferred_uac_device_candidates_expands_known_aliases() { + let candidates = preferred_uac_device_candidates("hw:UAC2Gadget,0"); + assert!(candidates.iter().any(|value| value == "hw:UAC2Gadget,0")); + assert!(candidates.iter().any(|value| value == "hw:UAC2_Gadget,0")); + assert!(candidates.iter().any(|value| value == "hw:Composite,0")); + } + + #[test] + fn detect_uac_card_candidates_returns_hw_names_only() { + let live = detect_uac_card_candidates(); + assert!(live.iter().all(|value| value.starts_with("hw:"))); + } + #[tokio::test] #[serial] async fn open_with_retry_opens_existing_file() {