lesavka: harden gadget audio and uvc startup

This commit is contained in:
Brad Stein 2026-04-20 02:28:16 -03:00
parent fc38925f0d
commit 50b5f54d27
8 changed files with 248 additions and 47 deletions

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.11.21"
version = "0.11.22"
edition = "2024"
[dependencies]

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.11.21"
version = "0.11.22"
edition = "2024"
build = "build.rs"

View File

@ -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)");
}
}

View File

@ -10,7 +10,7 @@ bench = false
[package]
name = "lesavka_server"
version = "0.11.21"
version = "0.11.22"
edition = "2024"
autobins = false

View File

@ -151,18 +151,41 @@ fn main() -> Result<()> {
let mut dq_err_last: Option<i32> = 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::<V4l2EventSubscription>(b'V', 90);
let vidioc_dqevent = ioctl_read::<V4l2Event>(b'V', 89);
let uvc_send_response = ioctl_write::<UvcRequestData>(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<PendingRequest> = 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::<std::io::Error>()
.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<String> = 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),
_ => {}
}
}

View File

@ -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()
});

View File

@ -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::<HashSet<_>>()
.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:#}")))?;

View File

@ -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<audio::Voice
#[cfg(not(coverage))]
pub async fn open_voice_with_retry(uac_dev: &str) -> anyhow::Result<audio::Voice> {
let attempts = std::env::var("LESAVKA_MIC_INIT_ATTEMPTS")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(5)
.max(1);
let delay_ms = std::env::var("LESAVKA_MIC_INIT_DELAY_MS")
.ok()
.and_then(|value| value.parse::<u64>().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<anyhow::Error> = 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::AudioStream> {
audio::ear(alsa_dev, id).await
}
#[cfg(not(coverage))]
pub async fn open_ear_with_retry(alsa_dev: &str, id: u32) -> anyhow::Result<audio::AudioStream> {
let candidates = preferred_uac_device_candidates(alsa_dev);
let (attempts, delay_ms) = audio_init_retry_policy();
let mut last_error: Option<anyhow::Error> = 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::<u32>().ok())
.or_else(|| {
std::env::var("LESAVKA_MIC_INIT_ATTEMPTS")
.ok()
.and_then(|value| value.parse::<u32>().ok())
})
.unwrap_or(20)
.max(1);
let delay_ms = std::env::var("LESAVKA_AUDIO_INIT_DELAY_MS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.or_else(|| {
std::env::var("LESAVKA_MIC_INIT_DELAY_MS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
})
.unwrap_or(250);
(attempts, delay_ms)
}
#[cfg(not(coverage))]
fn preferred_uac_device_candidates(preferred: &str) -> Vec<String> {
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<String>, seen: &mut BTreeSet<String>, 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<String> {
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() {