fix(recovery): make media recovery safe

This commit is contained in:
Brad Stein 2026-04-30 18:38:34 -03:00
parent d4a8b78eca
commit 8f319549e1
27 changed files with 472 additions and 61 deletions

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.16.7" version = "0.16.8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.16.7" version = "0.16.8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.16.7" version = "0.16.8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",

View File

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

View File

@ -16,17 +16,24 @@ enum CommandKind {
Auto, Auto,
On, On,
Off, Off,
RecoverUsb,
RecoverUac,
RecoverUvc,
ResetUsb, ResetUsb,
} }
impl CommandKind { impl CommandKind {
/// Parse the intentionally small CLI vocabulary into safe relay actions.
fn parse(value: &str) -> Option<Self> { fn parse(value: &str) -> Option<Self> {
match value { match value {
"status" | "get" => Some(Self::Status), "status" | "get" => Some(Self::Status),
"auto" => Some(Self::Auto), "auto" => Some(Self::Auto),
"on" | "force-on" => Some(Self::On), "on" | "force-on" => Some(Self::On),
"off" | "force-off" => Some(Self::Off), "off" | "force-off" => Some(Self::Off),
"reset-usb" | "recover-usb" => Some(Self::ResetUsb), "recover-usb" => Some(Self::RecoverUsb),
"recover-uac" => Some(Self::RecoverUac),
"recover-uvc" => Some(Self::RecoverUvc),
"reset-usb" | "hard-reset-usb" => Some(Self::ResetUsb),
_ => None, _ => None,
} }
} }
@ -45,7 +52,7 @@ enum ParseOutcome {
} }
fn usage() -> &'static str { fn usage() -> &'static str {
"Usage: lesavka-relayctl [--server http://HOST:50051] <status|auto|on|off|reset-usb>" "Usage: lesavka-relayctl [--server http://HOST:50051] <status|auto|on|off|recover-usb|recover-uac|recover-uvc|reset-usb>"
} }
fn parse_args_outcome_from<I, S>(args: I) -> Result<ParseOutcome> fn parse_args_outcome_from<I, S>(args: I) -> Result<ParseOutcome>
@ -103,7 +110,11 @@ fn parse_args() -> Result<ParseOutcome> {
fn capture_power_request(command: CommandKind) -> Option<SetCapturePowerRequest> { fn capture_power_request(command: CommandKind) -> Option<SetCapturePowerRequest> {
let (enabled, command) = match command { let (enabled, command) = match command {
CommandKind::Status | CommandKind::ResetUsb => return None, CommandKind::Status
| CommandKind::RecoverUsb
| CommandKind::RecoverUac
| CommandKind::RecoverUvc
| CommandKind::ResetUsb => return None,
CommandKind::Auto => (false, CapturePowerCommand::Auto), CommandKind::Auto => (false, CapturePowerCommand::Auto),
CommandKind::On => (true, CapturePowerCommand::ForceOn), CommandKind::On => (true, CapturePowerCommand::ForceOn),
CommandKind::Off => (false, CapturePowerCommand::ForceOff), CommandKind::Off => (false, CapturePowerCommand::ForceOff),
@ -160,7 +171,11 @@ async fn main() -> Result<()> {
CommandKind::Auto => "setting capture power to auto", CommandKind::Auto => "setting capture power to auto",
CommandKind::On => "forcing capture power on", CommandKind::On => "forcing capture power on",
CommandKind::Off => "forcing capture power off", CommandKind::Off => "forcing capture power off",
CommandKind::Status | CommandKind::ResetUsb => unreachable!(), CommandKind::Status
| CommandKind::RecoverUsb
| CommandKind::RecoverUac
| CommandKind::RecoverUvc
| CommandKind::ResetUsb => unreachable!(),
}; };
let reply = client let reply = client
.set_capture_power(Request::new(request)) .set_capture_power(Request::new(request))
@ -177,6 +192,33 @@ async fn main() -> Result<()> {
.await .await
.context("querying capture power state")? .context("querying capture power state")?
.into_inner(), .into_inner(),
CommandKind::RecoverUsb => {
let reply = client
.recover_usb(Request::new(Empty {}))
.await
.context("requesting soft USB recovery")?
.into_inner();
println!("ok={}", reply.ok);
return Ok(());
}
CommandKind::RecoverUac => {
let reply = client
.recover_uac(Request::new(Empty {}))
.await
.context("requesting soft UAC recovery")?
.into_inner();
println!("ok={}", reply.ok);
return Ok(());
}
CommandKind::RecoverUvc => {
let reply = client
.recover_uvc(Request::new(Empty {}))
.await
.context("requesting soft UVC recovery")?
.into_inner();
println!("ok={}", reply.ok);
return Ok(());
}
CommandKind::ResetUsb => { CommandKind::ResetUsb => {
let reply = client let reply = client
.reset_usb(Request::new(Empty {})) .reset_usb(Request::new(Empty {}))
@ -207,6 +249,7 @@ mod tests {
use lesavka_common::lesavka::CapturePowerState; use lesavka_common::lesavka::CapturePowerState;
#[test] #[test]
/// Verifies safe recovery commands stay separate from explicit hard reset.
fn command_aliases_parse_to_stable_actions() { fn command_aliases_parse_to_stable_actions() {
assert_eq!(CommandKind::parse("status"), Some(CommandKind::Status)); assert_eq!(CommandKind::parse("status"), Some(CommandKind::Status));
assert_eq!(CommandKind::parse("get"), Some(CommandKind::Status)); assert_eq!(CommandKind::parse("get"), Some(CommandKind::Status));
@ -214,6 +257,18 @@ mod tests {
assert_eq!(CommandKind::parse("force-off"), Some(CommandKind::Off)); assert_eq!(CommandKind::parse("force-off"), Some(CommandKind::Off));
assert_eq!( assert_eq!(
CommandKind::parse("recover-usb"), CommandKind::parse("recover-usb"),
Some(CommandKind::RecoverUsb)
);
assert_eq!(
CommandKind::parse("recover-uac"),
Some(CommandKind::RecoverUac)
);
assert_eq!(
CommandKind::parse("recover-uvc"),
Some(CommandKind::RecoverUvc)
);
assert_eq!(
CommandKind::parse("hard-reset-usb"),
Some(CommandKind::ResetUsb) Some(CommandKind::ResetUsb)
); );
assert_eq!(CommandKind::parse("wat"), None); assert_eq!(CommandKind::parse("wat"), None);
@ -272,6 +327,7 @@ mod tests {
} }
#[test] #[test]
/// Keeps status/read commands from accidentally mutating capture power.
fn mutating_commands_map_to_capture_power_requests() { fn mutating_commands_map_to_capture_power_requests() {
let auto = capture_power_request(CommandKind::Auto).expect("auto request"); let auto = capture_power_request(CommandKind::Auto).expect("auto request");
assert!(!auto.enabled); assert!(!auto.enabled);
@ -286,6 +342,9 @@ mod tests {
assert_eq!(off.command, CapturePowerCommand::ForceOff as i32); assert_eq!(off.command, CapturePowerCommand::ForceOff as i32);
assert!(capture_power_request(CommandKind::Status).is_none()); assert!(capture_power_request(CommandKind::Status).is_none());
assert!(capture_power_request(CommandKind::RecoverUsb).is_none());
assert!(capture_power_request(CommandKind::RecoverUac).is_none());
assert!(capture_power_request(CommandKind::RecoverUvc).is_none());
assert!(capture_power_request(CommandKind::ResetUsb).is_none()); assert!(capture_power_request(CommandKind::ResetUsb).is_none());
} }

View File

@ -61,3 +61,51 @@ include!("camera/encoder_selection.rs");
include!("camera/source_description.rs"); include!("camera/source_description.rs");
include!("camera/preview_tap.rs"); include!("camera/preview_tap.rs");
include!("camera/bus_and_encoder.rs"); include!("camera/bus_and_encoder.rs");
#[cfg(test)]
mod tests {
use super::{CameraCodec, CameraConfig, resolved_capture_profile};
use serial_test::serial;
#[test]
#[serial]
/// Guards the browser-facing UVC contract against launcher preview overrides.
fn negotiated_capture_profile_overrides_launcher_quality_env_by_default() {
let cfg = CameraConfig {
codec: CameraCodec::Mjpeg,
width: 640,
height: 480,
fps: 20,
};
temp_env::with_vars(
[
("LESAVKA_CAM_WIDTH", Some("1280")),
("LESAVKA_CAM_HEIGHT", Some("720")),
("LESAVKA_CAM_FPS", Some("30")),
("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE", None),
],
|| assert_eq!(resolved_capture_profile(Some(cfg)), (640, 480, 20)),
);
}
#[test]
#[serial]
/// Keeps the explicit lab override available for controlled camera debugging.
fn explicit_profile_override_keeps_lab_mode_available() {
let cfg = CameraConfig {
codec: CameraCodec::Mjpeg,
width: 640,
height: 480,
fps: 20,
};
temp_env::with_vars(
[
("LESAVKA_CAM_WIDTH", Some("1280")),
("LESAVKA_CAM_HEIGHT", Some("720")),
("LESAVKA_CAM_FPS", Some("30")),
("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE", Some("1")),
],
|| assert_eq!(resolved_capture_profile(Some(cfg)), (1280, 720, 30)),
);
}
}

View File

@ -40,9 +40,7 @@ impl CameraCapture {
|cfg| matches!(cfg.codec, CameraCodec::Mjpeg), |cfg| matches!(cfg.codec, CameraCodec::Mjpeg),
); );
let jpeg_quality = env_u32("LESAVKA_CAM_JPEG_QUALITY", 85).clamp(1, 100); let jpeg_quality = env_u32("LESAVKA_CAM_JPEG_QUALITY", 85).clamp(1, 100);
let width = env_u32("LESAVKA_CAM_WIDTH", cfg.map_or(1280, |cfg| cfg.width)); let (width, height, fps) = resolved_capture_profile(cfg);
let height = env_u32("LESAVKA_CAM_HEIGHT", cfg.map_or(720, |cfg| cfg.height));
let fps = env_u32("LESAVKA_CAM_FPS", cfg.map_or(25, |cfg| cfg.fps)).max(1);
let keyframe_interval = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(5)).clamp(1, fps); let keyframe_interval = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(5)).clamp(1, fps);
let source_profile = camera_source_profile(allow_mjpg_source); let source_profile = camera_source_profile(allow_mjpg_source);
let use_mjpg_source = source_profile == CameraSourceProfile::Mjpeg; let use_mjpg_source = source_profile == CameraSourceProfile::Mjpeg;
@ -261,6 +259,33 @@ impl CameraCapture {
} }
} }
/// Resolve the exact profile the client sends, preferring the server UVC contract.
fn resolved_capture_profile(cfg: Option<CameraConfig>) -> (u32, u32, u32) {
match cfg {
Some(cfg) if !env_flag_enabled("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE") => {
return (cfg.width, cfg.height, cfg.fps.max(1));
}
_ => {}
}
(
env_u32("LESAVKA_CAM_WIDTH", cfg.map_or(1280, |cfg| cfg.width)),
env_u32("LESAVKA_CAM_HEIGHT", cfg.map_or(720, |cfg| cfg.height)),
env_u32("LESAVKA_CAM_FPS", cfg.map_or(25, |cfg| cfg.fps)).max(1),
)
}
fn env_flag_enabled(name: &str) -> bool {
std::env::var(name).ok().is_some_and(|value| {
let trimmed = value.trim();
!(trimmed.is_empty()
|| trimmed.eq_ignore_ascii_case("0")
|| trimmed.eq_ignore_ascii_case("false")
|| trimmed.eq_ignore_ascii_case("no")
|| trimmed.eq_ignore_ascii_case("off"))
})
}
fn log_camera_first_packet(packet_index: u64, bytes: usize, pts_us: u64) { fn log_camera_first_packet(packet_index: u64, bytes: usize, pts_us: u64) {
if packet_index == 0 { if packet_index == 0 {
tracing::info!(bytes, pts_us, "📸 upstream webcam frames flowing"); tracing::info!(bytes, pts_us, "📸 upstream webcam frames flowing");

View File

@ -37,18 +37,52 @@ pub fn set_capture_power_mode(
}) })
} }
pub fn reset_usb_gadget(server_addr: &str) -> Result<()> { pub fn recover_usb_soft(server_addr: &str) -> Result<()> {
run_recovery(server_addr, RecoveryKind::Usb)
}
pub fn recover_uac_soft(server_addr: &str) -> Result<()> {
run_recovery(server_addr, RecoveryKind::Uac)
}
pub fn recover_uvc_soft(server_addr: &str) -> Result<()> {
run_recovery(server_addr, RecoveryKind::Uvc)
}
#[derive(Clone, Copy)]
enum RecoveryKind {
Usb,
Uac,
Uvc,
}
fn run_recovery(server_addr: &str, kind: RecoveryKind) -> Result<()> {
with_runtime(async move { with_runtime(async move {
let mut client = connect(server_addr).await?; let mut client = connect(server_addr).await?;
let reply = client let request = Request::new(Empty {});
.reset_usb(Request::new(Empty {})) let response = match kind {
.await RecoveryKind::Usb => client
.context("requesting USB gadget reset")? .recover_usb(request)
.into_inner(); .await
.context("requesting soft USB recovery")?,
RecoveryKind::Uac => client
.recover_uac(request)
.await
.context("requesting soft UAC recovery")?,
RecoveryKind::Uvc => client
.recover_uvc(request)
.await
.context("requesting soft UVC recovery")?,
};
let reply = response.into_inner();
if reply.ok { if reply.ok {
Ok(()) Ok(())
} else { } else {
anyhow::bail!("relay reported USB reset failure"); match kind {
RecoveryKind::Usb => anyhow::bail!("relay reported soft USB recovery failure"),
RecoveryKind::Uac => anyhow::bail!("relay reported soft UAC recovery failure"),
RecoveryKind::Uvc => anyhow::bail!("relay reported soft UVC recovery failure"),
}
} }
}) })
} }

View File

@ -109,6 +109,33 @@ impl Relay for ProbeRelay {
})) }))
} }
async fn recover_usb(
&self,
_request: Request<lesavka_common::lesavka::Empty>,
) -> Result<Response<lesavka_common::lesavka::ResetUsbReply>, Status> {
Ok(Response::new(lesavka_common::lesavka::ResetUsbReply {
ok: true,
}))
}
async fn recover_uac(
&self,
_request: Request<lesavka_common::lesavka::Empty>,
) -> Result<Response<lesavka_common::lesavka::ResetUsbReply>, Status> {
Ok(Response::new(lesavka_common::lesavka::ResetUsbReply {
ok: true,
}))
}
async fn recover_uvc(
&self,
_request: Request<lesavka_common::lesavka::Empty>,
) -> Result<Response<lesavka_common::lesavka::ResetUsbReply>, Status> {
Ok(Response::new(lesavka_common::lesavka::ResetUsbReply {
ok: true,
}))
}
async fn get_capture_power( async fn get_capture_power(
&self, &self,
_request: Request<lesavka_common::lesavka::Empty>, _request: Request<lesavka_common::lesavka::Empty>,

View File

@ -1,4 +1,7 @@
use super::super::{clipboard::send_clipboard_text_to_remote, power::reset_usb_gadget}; use super::super::{
clipboard::send_clipboard_text_to_remote,
power::{recover_uac_soft, recover_usb_soft, recover_uvc_soft},
};
use futures::stream; use futures::stream;
use lesavka_common::lesavka::{ use lesavka_common::lesavka::{
AudioPacket, CalibrationRequest, CalibrationState, CapturePowerState, Empty, KeyboardReport, AudioPacket, CalibrationRequest, CalibrationState, CapturePowerState, Empty, KeyboardReport,
@ -104,6 +107,30 @@ impl Relay for UtilityRelay {
Ok(Response::new(ResetUsbReply { ok: self.reset_ok })) Ok(Response::new(ResetUsbReply { ok: self.reset_ok }))
} }
async fn recover_usb(
&self,
_request: Request<Empty>,
) -> Result<Response<ResetUsbReply>, Status> {
*self.reset_count.lock().expect("reset count") += 1;
Ok(Response::new(ResetUsbReply { ok: self.reset_ok }))
}
async fn recover_uac(
&self,
_request: Request<Empty>,
) -> Result<Response<ResetUsbReply>, Status> {
*self.reset_count.lock().expect("reset count") += 1;
Ok(Response::new(ResetUsbReply { ok: self.reset_ok }))
}
async fn recover_uvc(
&self,
_request: Request<Empty>,
) -> Result<Response<ResetUsbReply>, Status> {
*self.reset_count.lock().expect("reset count") += 1;
Ok(Response::new(ResetUsbReply { ok: self.reset_ok }))
}
async fn get_capture_power( async fn get_capture_power(
&self, &self,
_request: Request<Empty>, _request: Request<Empty>,
@ -183,13 +210,25 @@ fn recover_usb_action_reports_relay_success_and_failure() {
let relay = UtilityRelay::new(true); let relay = UtilityRelay::new(true);
let reset_count = Arc::clone(&relay.reset_count); let reset_count = Arc::clone(&relay.reset_count);
let (_rt, addr) = serve(relay); let (_rt, addr) = serve(relay);
reset_usb_gadget(&addr).expect("successful reset"); recover_usb_soft(&addr).expect("successful soft USB recovery");
assert_eq!(*reset_count.lock().expect("reset count"), 1); assert_eq!(*reset_count.lock().expect("reset count"), 1);
let relay = UtilityRelay::new(false); let relay = UtilityRelay::new(false);
let reset_count = Arc::clone(&relay.reset_count); let reset_count = Arc::clone(&relay.reset_count);
let (_rt, addr) = serve(relay); let (_rt, addr) = serve(relay);
let err = reset_usb_gadget(&addr).expect_err("reset failure"); let err = recover_usb_soft(&addr).expect_err("soft USB recovery failure");
assert!(format!("{err:#}").contains("relay reported USB reset failure")); assert!(format!("{err:#}").contains("relay reported soft USB recovery failure"));
assert_eq!(*reset_count.lock().expect("reset count"), 1); assert_eq!(*reset_count.lock().expect("reset count"), 1);
} }
#[test]
#[serial]
fn soft_recovery_actions_report_relay_success() {
let relay = UtilityRelay::new(true);
let reset_count = Arc::clone(&relay.reset_count);
let (_rt, addr) = serve(relay);
recover_usb_soft(&addr).expect("usb soft recover");
recover_uac_soft(&addr).expect("uac soft recover");
recover_uvc_soft(&addr).expect("uvc soft recover");
assert_eq!(*reset_count.lock().expect("reset count"), 3);
}

View File

@ -12,7 +12,10 @@ use {
super::diagnostics::PerformanceSample, super::diagnostics::PerformanceSample,
super::launcher_clipboard_control_path, super::launcher_clipboard_control_path,
super::launcher_focus_signal_path, super::launcher_focus_signal_path,
super::power::{fetch_capture_power, reset_usb_gadget, set_capture_power_mode}, super::power::{
fetch_capture_power, recover_uac_soft, recover_usb_soft, recover_uvc_soft,
set_capture_power_mode,
},
super::preview::{LauncherPreview, PreviewSurface}, super::preview::{LauncherPreview, PreviewSurface},
super::state::{ super::state::{
BreakoutSizePreset, CalibrationStatus, CapturePowerStatus, CaptureSizePreset, BreakoutSizePreset, CalibrationStatus, CapturePowerStatus, CaptureSizePreset,

View File

@ -161,18 +161,18 @@
widgets.usb_recover_button.connect_clicked(move |_| { widgets.usb_recover_button.connect_clicked(move |_| {
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
widgets_for_click.status_label.set_text( widgets_for_click.status_label.set_text(
"Recover USB 1/3: sending gadget reset request to relay host...", "Recover USB 1/3: reopening HID handles and checking enumeration...",
); );
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || { std::thread::spawn(move || {
let result = reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}")); let result = recover_usb_soft(&server_addr).map_err(|err| format!("{err:#}"));
let _ = tx.send(result); let _ = tx.send(result);
}); });
let widgets = widgets_for_click.clone(); let widgets = widgets_for_click.clone();
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() { glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
Ok(Ok(())) => { Ok(Ok(())) => {
widgets.status_label.set_text( widgets.status_label.set_text(
"Recover USB 2/3: relay acknowledged reset. Recover USB 3/3: waiting for USB/UAC/UVC chips to settle.", "Recover USB 2/3: HID reopened. Recover USB 3/3: watching chips for stable enumeration.",
); );
glib::ControlFlow::Break glib::ControlFlow::Break
} }
@ -202,17 +202,17 @@
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
widgets_for_click widgets_for_click
.status_label .status_label
.set_text("Recover UAC 1/3: sending gadget reset request to relay host..."); .set_text("Recover UAC 1/3: asking microphone stream to stand down cleanly...");
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || { std::thread::spawn(move || {
let result = reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}")); let result = recover_uac_soft(&server_addr).map_err(|err| format!("{err:#}"));
let _ = tx.send(result); let _ = tx.send(result);
}); });
let widgets = widgets_for_click.clone(); let widgets = widgets_for_click.clone();
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() { glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
Ok(Ok(())) => { Ok(Ok(())) => {
widgets.status_label.set_text( widgets.status_label.set_text(
"Recover UAC 2/3: relay acknowledged reset. Recover UAC 3/3: waiting for UAC chip to settle.", "Recover UAC 2/3: old mic sink released. Recover UAC 3/3: client will reconnect without resetting USB.",
); );
glib::ControlFlow::Break glib::ControlFlow::Break
} }
@ -242,17 +242,17 @@
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
widgets_for_click widgets_for_click
.status_label .status_label
.set_text("Recover UVC 1/3: sending gadget reset request to relay host..."); .set_text("Recover UVC 1/3: retiring the webcam spool pipeline safely...");
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || { std::thread::spawn(move || {
let result = reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}")); let result = recover_uvc_soft(&server_addr).map_err(|err| format!("{err:#}"));
let _ = tx.send(result); let _ = tx.send(result);
}); });
let widgets = widgets_for_click.clone(); let widgets = widgets_for_click.clone();
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() { glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
Ok(Ok(())) => { Ok(Ok(())) => {
widgets.status_label.set_text( widgets.status_label.set_text(
"Recover UVC 2/3: relay acknowledged reset. Recover UVC 3/3: waiting for UVC chip to settle.", "Recover UVC 2/3: webcam sink retired. Recover UVC 3/3: client will recreate the UVC spool on reconnect.",
); );
glib::ControlFlow::Break glib::ControlFlow::Break
} }

View File

@ -38,9 +38,18 @@
let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8); let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
recovery_buttons.set_hexpand(true); recovery_buttons.set_hexpand(true);
recovery_buttons.set_homogeneous(true); recovery_buttons.set_homogeneous(true);
let usb_recover_button = rail_button("USB", "Re-enumerate remote USB gadget."); let usb_recover_button = rail_button(
let uac_recover_button = rail_button("UAC", "Rebuild remote USB audio function."); "USB",
let uvc_recover_button = rail_button("UVC", "Rebuild remote USB webcam function."); "Soft recovery: reopen HID and verify enumeration without detaching the USB gadget.",
);
let uac_recover_button = rail_button(
"UAC",
"Soft recovery: retire the active mic sink so the audio stream reconnects without resetting USB.",
);
let uvc_recover_button = rail_button(
"UVC",
"Soft recovery: retire the webcam spool pipeline so video reconnects without restarting the gadget.",
);
recovery_buttons.append(&usb_recover_button); recovery_buttons.append(&usb_recover_button);
recovery_buttons.append(&uac_recover_button); recovery_buttons.append(&uac_recover_button);
recovery_buttons.append(&uvc_recover_button); recovery_buttons.append(&uvc_recover_button);

View File

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

View File

@ -114,6 +114,9 @@ service Relay {
rpc StreamCamera (stream VideoPacket) returns (stream Empty); rpc StreamCamera (stream VideoPacket) returns (stream Empty);
rpc PasteText (PasteRequest) returns (PasteReply); rpc PasteText (PasteRequest) returns (PasteReply);
rpc RecoverUsb (Empty) returns (ResetUsbReply);
rpc RecoverUac (Empty) returns (ResetUsbReply);
rpc RecoverUvc (Empty) returns (ResetUsbReply);
rpc ResetUsb (Empty) returns (ResetUsbReply); rpc ResetUsb (Empty) returns (ResetUsbReply);
rpc GetCapturePower (Empty) returns (CapturePowerState); rpc GetCapturePower (Empty) returns (CapturePowerState);
rpc SetCapturePower (SetCapturePowerRequest) returns (CapturePowerState); rpc SetCapturePower (SetCapturePowerRequest) returns (CapturePowerState);

View File

@ -35,6 +35,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
| `LESAVKA_BREAKOUT_REQUEST_FPS` | eye preview/video transport override | | `LESAVKA_BREAKOUT_REQUEST_FPS` | eye preview/video transport override |
| `LESAVKA_BREAKOUT_REQUEST_HEIGHT` | eye preview/video transport override | | `LESAVKA_BREAKOUT_REQUEST_HEIGHT` | eye preview/video transport override |
| `LESAVKA_BREAKOUT_REQUEST_WIDTH` | eye preview/video transport override | | `LESAVKA_BREAKOUT_REQUEST_WIDTH` | eye preview/video transport override |
| `LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE` | lab-only override that lets launcher camera width/height/fps env values beat the server-negotiated UVC profile |
| `LESAVKA_CAM_BY_ID_DIR` | client media capture/playback override | | `LESAVKA_CAM_BY_ID_DIR` | client media capture/playback override |
| `LESAVKA_CAM_CODEC` | client media capture/playback override | | `LESAVKA_CAM_CODEC` | client media capture/playback override |
| `LESAVKA_CAM_DEV_ROOT` | client media capture/playback override | | `LESAVKA_CAM_DEV_ROOT` | client media capture/playback override |

View File

@ -37,8 +37,8 @@
}, },
"client/src/bin/lesavka-relayctl.rs": { "client/src/bin/lesavka-relayctl.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 6, "doc_debt": 4,
"loc": 304 "loc": 363
}, },
"client/src/bin/lesavka-sync-analyze.rs": { "client/src/bin/lesavka-sync-analyze.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -58,7 +58,7 @@
"client/src/input/camera.rs": { "client/src/input/camera.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 0, "doc_debt": 0,
"loc": 63 "loc": 111
}, },
"client/src/input/camera/bus_and_encoder.rs": { "client/src/input/camera/bus_and_encoder.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -68,7 +68,7 @@
"client/src/input/camera/capture_pipeline.rs": { "client/src/input/camera/capture_pipeline.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 4, "doc_debt": 4,
"loc": 295 "loc": 320
}, },
"client/src/input/camera/device_selection.rs": { "client/src/input/camera/device_selection.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -228,12 +228,12 @@
"client/src/launcher/mod.rs": { "client/src/launcher/mod.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 5, "doc_debt": 5,
"loc": 246 "loc": 240
}, },
"client/src/launcher/power.rs": { "client/src/launcher/power.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 2, "doc_debt": 2,
"loc": 86 "loc": 120
}, },
"client/src/launcher/preview.rs": { "client/src/launcher/preview.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -293,7 +293,7 @@
"client/src/launcher/ui.rs": { "client/src/launcher/ui.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 1, "doc_debt": 1,
"loc": 194 "loc": 197
}, },
"client/src/launcher/ui/activation_context.rs": { "client/src/launcher/ui/activation_context.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -408,7 +408,7 @@
"client/src/launcher/ui_components/build_operations_rail.rs": { "client/src/launcher/ui_components/build_operations_rail.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 0, "doc_debt": 0,
"loc": 323 "loc": 332
}, },
"client/src/launcher/ui_components/build_shell.rs": { "client/src/launcher/ui_components/build_shell.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -506,9 +506,9 @@
"loc": 429 "loc": 429
}, },
"client/src/live_media_control.rs": { "client/src/live_media_control.rs": {
"loc": 200,
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 3 "doc_debt": 3,
"loc": 200
}, },
"client/src/main.rs": { "client/src/main.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -703,7 +703,7 @@
"server/src/audio/ear_capture.rs": { "server/src/audio/ear_capture.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 5, "doc_debt": 5,
"loc": 460 "loc": 452
}, },
"server/src/audio/voice_input.rs": { "server/src/audio/voice_input.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -758,7 +758,7 @@
"server/src/camera_runtime.rs": { "server/src/camera_runtime.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 3, "doc_debt": 3,
"loc": 211 "loc": 230
}, },
"server/src/capture_power.rs": { "server/src/capture_power.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -813,7 +813,7 @@
"server/src/main.rs": { "server/src/main.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 1, "doc_debt": 1,
"loc": 100 "loc": 101
}, },
"server/src/main/entrypoint.rs": { "server/src/main/entrypoint.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -843,7 +843,7 @@
"server/src/main/relay_service_coverage.rs": { "server/src/main/relay_service_coverage.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 5, "doc_debt": 5,
"loc": 301 "loc": 322
}, },
"server/src/main/relay_service_tests.rs": { "server/src/main/relay_service_tests.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -858,7 +858,7 @@
"server/src/main/rpc_helpers.rs": { "server/src/main/rpc_helpers.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 3, "doc_debt": 3,
"loc": 118 "loc": 167
}, },
"server/src/main/usb_recovery_helpers.rs": { "server/src/main/usb_recovery_helpers.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -913,7 +913,7 @@
"server/src/upstream_media_runtime/lease_lifecycle.rs": { "server/src/upstream_media_runtime/lease_lifecycle.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 3, "doc_debt": 3,
"loc": 142 "loc": 153
}, },
"server/src/upstream_media_runtime/state.rs": { "server/src/upstream_media_runtime/state.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,

View File

@ -457,6 +457,15 @@ if [[ -z $DISABLE_UAC ]]; then
echo adaptive >"$U/c_sync" 2>/dev/null || true echo adaptive >"$U/c_sync" 2>/dev/null || true
# Optional: allocate a few extra request buffers # Optional: allocate a few extra request buffers
echo 32 >"$U/req_number" 2>/dev/null || true echo 32 >"$U/req_number" 2>/dev/null || true
# Friendly host-side labels when this kernel exposes UAC2 descriptor names.
# Browsers still choose their own wording, but these make Lesavka easier to
# recognize than the generic "Multifunction Composite Gadget" fallback.
echo "Lesavka Audio Bridge" >"$U/function_name" 2>/dev/null || true
echo "Lesavka Audio Control" >"$U/if_ctrl_name" 2>/dev/null || true
echo "Lesavka Speaker Playback" >"$U/p_it_name" 2>/dev/null || true
echo "Lesavka Host Speaker" >"$U/p_ot_name" 2>/dev/null || true
echo "Lesavka Remote Mic" >"$U/c_it_name" 2>/dev/null || true
echo "Lesavka Microphone" >"$U/c_ot_name" 2>/dev/null || true
else else
log "🔇 UAC2 disabled (LESAVKA_DISABLE_UAC set)" log "🔇 UAC2 disabled (LESAVKA_DISABLE_UAC set)"
fi fi

View File

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

View File

@ -116,6 +116,25 @@ impl CameraRuntime {
self.generation.load(Ordering::Relaxed) == session_id self.generation.load(Ordering::Relaxed) == session_id
} }
/// Supersede the active camera stream and drop the userspace relay sink.
///
/// Inputs: none.
/// Outputs: none.
/// Why: UVC recovery should make the client reconnect and recreate the
/// spool/appsrc pipeline without cycling the USB controller while a browser
/// may still own the gadget.
#[cfg(coverage)]
pub async fn soft_recover(&self) {
self.generation.fetch_add(1, Ordering::SeqCst);
}
#[cfg(not(coverage))]
pub async fn soft_recover(&self) {
self.generation.fetch_add(1, Ordering::SeqCst);
let mut slot = self.slot.lock().await;
let _dropped = slot.take();
}
#[allow(clippy::result_large_err)] #[allow(clippy::result_large_err)]
#[cfg(not(coverage))] #[cfg(not(coverage))]
fn make_relay(&self, cfg: &camera::CameraConfig) -> Result<Arc<video::CameraRelay>, Status> { fn make_relay(&self, cfg: &camera::CameraConfig) -> Result<Arc<video::CameraRelay>, Status> {

View File

@ -36,6 +36,7 @@ const PKG_NAME: &str = env!("CARGO_PKG_NAME");
type VideoStream = Pin<Box<dyn Stream<Item = Result<VideoPacket, Status>> + Send>>; type VideoStream = Pin<Box<dyn Stream<Item = Result<VideoPacket, Status>> + Send>>;
type AudioStream = Pin<Box<dyn Stream<Item = Result<AudioPacket, Status>> + Send>>; type AudioStream = Pin<Box<dyn Stream<Item = Result<AudioPacket, Status>> + Send>>;
type ResetReply = Result<Response<ResetUsbReply>, Status>;
fn hid_endpoint(index: u8) -> String { fn hid_endpoint(index: u8) -> String {
std::env::var("LESAVKA_HID_DIR") std::env::var("LESAVKA_HID_DIR")

View File

@ -460,10 +460,10 @@ impl Relay for Handler {
self.paste_text_reply(req).await self.paste_text_reply(req).await
} }
/*────────────── USB-reset RPC ────────────*/ async fn recover_usb(&self, _req: Request<Empty>) -> ResetReply { self.recover_usb_reply().await }
async fn reset_usb(&self, _req: Request<Empty>) -> Result<Response<ResetUsbReply>, Status> { async fn recover_uac(&self, _req: Request<Empty>) -> ResetReply { self.recover_uac_reply().await }
self.reset_usb_reply().await async fn recover_uvc(&self, _req: Request<Empty>) -> ResetReply { self.recover_uvc_reply().await }
} async fn reset_usb(&self, _req: Request<Empty>) -> ResetReply { self.reset_usb_reply().await }
async fn get_capture_power( async fn get_capture_power(
&self, &self,

View File

@ -271,6 +271,27 @@ impl Relay for Handler {
self.reset_usb_reply().await self.reset_usb_reply().await
} }
async fn recover_usb(
&self,
_req: Request<Empty>,
) -> Result<Response<ResetUsbReply>, Status> {
self.recover_usb_reply().await
}
async fn recover_uac(
&self,
_req: Request<Empty>,
) -> Result<Response<ResetUsbReply>, Status> {
self.recover_uac_reply().await
}
async fn recover_uvc(
&self,
_req: Request<Empty>,
) -> Result<Response<ResetUsbReply>, Status> {
self.recover_uvc_reply().await
}
async fn get_capture_power( async fn get_capture_power(
&self, &self,
_req: Request<Empty>, _req: Request<Empty>,

View File

@ -1,4 +1,53 @@
impl Handler { impl Handler {
/// Reopen HID handles and verify enumeration without cycling the USB gadget.
async fn recover_usb_reply(&self) -> Result<Response<ResetUsbReply>, Status> {
#[cfg(not(coverage))]
info!("🛟 RecoverUsb() soft recovery called");
self.reopen_hid()
.await
.map_err(|e| Status::internal(format!("soft USB HID reopen failed: {e:#}")))?;
match current_controller_state_after_recovery() {
Ok((ctrl, state)) if UsbGadget::host_enumerated_state(&state) => {
#[cfg(not(coverage))]
info!("✅ soft USB recovery verified host enumeration ctrl={ctrl} state={state}");
Ok(Response::new(ResetUsbReply { ok: true }))
}
Ok((ctrl, state)) => Err(Status::failed_precondition(format!(
"soft USB recovery refused hard reset: UDC {ctrl} is {state}; use explicit hard ResetUsb only after closing remote UVC/audio consumers"
))),
Err(err) => Err(Status::failed_precondition(format!(
"soft USB recovery could not read UDC state: {err:#}"
))),
}
}
async fn recover_uac_reply(&self) -> Result<Response<ResetUsbReply>, Status> {
#[cfg(not(coverage))]
info!("🛟 RecoverUac() soft recovery called");
self.upstream_media_rt.soft_recover_microphone();
Ok(Response::new(ResetUsbReply { ok: true }))
}
/// Retire the current webcam relay sink without detaching the UVC gadget.
async fn recover_uvc_reply(&self) -> Result<Response<ResetUsbReply>, Status> {
#[cfg(not(coverage))]
info!("🛟 RecoverUvc() soft recovery called");
let cfg = camera::current_camera_config();
if cfg.output != camera::CameraOutput::Uvc {
return Err(Status::failed_precondition(format!(
"soft UVC recovery skipped because active camera output is {}",
cfg.output.as_str()
)));
}
self.camera_rt.soft_recover().await;
Ok(Response::new(ResetUsbReply { ok: true }))
}
async fn paste_text_reply( async fn paste_text_reply(
&self, &self,
req: Request<PasteRequest>, req: Request<PasteRequest>,

View File

@ -105,6 +105,17 @@ impl UpstreamMediaRuntime {
self.close(UpstreamMediaKind::Microphone, generation); self.close(UpstreamMediaKind::Microphone, generation);
} }
/// Supersede any current microphone owner without touching the USB gadget.
///
/// Inputs: none.
/// Outputs: none.
/// Why: UAC recovery should force the active gRPC/audio sink to drain and
/// reconnect, not reset UDC or disturb UVC/HID.
pub fn soft_recover_microphone(&self) {
let lease = self.activate_microphone();
self.close_microphone(lease.generation);
}
fn close(&self, kind: UpstreamMediaKind, generation: u64) { fn close(&self, kind: UpstreamMediaKind, generation: u64) {
let mut state = self let mut state = self
.state .state

View File

@ -189,3 +189,37 @@ fn close_ignores_superseded_generation_values() {
let next = runtime.activate_camera(); let next = runtime.activate_camera();
assert_eq!(next.session_id, 2); assert_eq!(next.session_id, 2);
} }
#[test]
#[serial(upstream_media_runtime)]
fn soft_microphone_recovery_preserves_camera_and_shared_clock() {
let runtime = runtime_without_offsets();
let camera = runtime.activate_camera();
let microphone = runtime.activate_microphone();
assert!(matches!(
runtime.plan_video_pts(1_000_000, 16_666),
super::UpstreamPlanDecision::AwaitingPair
));
let audio_first = play(runtime.plan_audio_pts(1_000_000));
let video_first = play(runtime.plan_video_pts(1_000_000, 16_666));
assert_eq!(audio_first.local_pts_us, video_first.local_pts_us);
runtime.soft_recover_microphone();
assert!(runtime.is_camera_active(camera.generation));
assert!(!runtime.is_microphone_active(microphone.generation));
let rt = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.expect("runtime");
assert!(
rt.block_on(runtime.wait_for_audio_master(video_first.local_pts_us, video_first.due_at)),
"video should not wait on a soft-recovered microphone slot"
);
let next_microphone = runtime.activate_microphone();
assert_eq!(next_microphone.session_id, camera.session_id);
let recovered_audio = play(runtime.plan_audio_pts(1_010_000));
assert_eq!(recovered_audio.local_pts_us, 10_000);
}

View File

@ -167,7 +167,8 @@ fn launcher_webcam_quality_selection_reaches_preview_and_relay_env() {
assert!(DEVICE_TEST_SRC.contains("pub fn set_camera_quality")); assert!(DEVICE_TEST_SRC.contains("pub fn set_camera_quality"));
assert!(DEVICE_TEST_SRC.contains("build_camera_preview_pipeline(&device, mode)")); assert!(DEVICE_TEST_SRC.contains("build_camera_preview_pipeline(&device, mode)"));
assert!(DEVICE_TEST_SRC.contains("capsfilter caps=\\\"video/x-raw")); assert!(DEVICE_TEST_SRC.contains("capsfilter caps=\\\"video/x-raw"));
assert!(CAMERA_SRC.contains("env_u32(\"LESAVKA_CAM_WIDTH\", cfg.map_or")); assert!(CAMERA_SRC.contains("fn resolved_capture_profile"));
assert!(CAMERA_SRC.contains("LESAVKA_CAM_ALLOW_PROFILE_OVERRIDE"));
assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_WIDTH\"")); assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_WIDTH\""));
assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_H264_KBIT\"")); assert!(LAUNCHER_MOD_SRC.contains("\"LESAVKA_CAM_H264_KBIT\""));
} }
@ -191,8 +192,10 @@ fn launcher_utility_buttons_still_bind_to_live_actions() {
assert!(UI_SRC.contains("recording saved to")); assert!(UI_SRC.contains("recording saved to"));
assert!(UI_SRC.contains("press Stop to finish.")); assert!(UI_SRC.contains("press Stop to finish."));
assert!(UI_SRC.contains("widgets.usb_recover_button.connect_clicked")); assert!(UI_SRC.contains("widgets.usb_recover_button.connect_clicked"));
assert!(UI_SRC.contains("reset_usb_gadget(&server_addr)")); assert!(UI_SRC.contains("recover_usb_soft(&server_addr)"));
assert!(UI_SRC.contains("Recover USB 2/3: relay acknowledged reset.")); assert!(UI_SRC.contains("recover_uac_soft(&server_addr)"));
assert!(UI_SRC.contains("recover_uvc_soft(&server_addr)"));
assert!(UI_SRC.contains("Recover USB 2/3: HID reopened."));
} }
#[test] #[test]

View File

@ -22,10 +22,24 @@ mod relayctl_binary {
assert_eq!(CommandKind::parse("reset-usb"), Some(CommandKind::ResetUsb)); assert_eq!(CommandKind::parse("reset-usb"), Some(CommandKind::ResetUsb));
assert_eq!( assert_eq!(
CommandKind::parse("recover-usb"), CommandKind::parse("recover-usb"),
Some(CommandKind::RecoverUsb)
);
assert_eq!(
CommandKind::parse("recover-uac"),
Some(CommandKind::RecoverUac)
);
assert_eq!(
CommandKind::parse("recover-uvc"),
Some(CommandKind::RecoverUvc)
);
assert_eq!(
CommandKind::parse("hard-reset-usb"),
Some(CommandKind::ResetUsb) Some(CommandKind::ResetUsb)
); );
assert_eq!(CommandKind::parse("bad"), None); assert_eq!(CommandKind::parse("bad"), None);
assert!(usage().contains("lesavka-relayctl")); assert!(usage().contains("lesavka-relayctl"));
assert!(usage().contains("recover-uac"));
assert!(usage().contains("recover-uvc"));
} }
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]

View File

@ -115,7 +115,9 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
"LESAVKA_ALLOW_GADGET_RESET should permit recovery without causing unconditional hard rebuilds" "LESAVKA_ALLOW_GADGET_RESET should permit recovery without causing unconditional hard rebuilds"
); );
assert!( assert!(
!SERVER_INSTALL.contains("[[ -n ${LESAVKA_ALLOW_GADGET_RESET:-} ]] || [[ \"$FORCE_GADGET_REBUILD\" == \"1\" ]]"), !SERVER_INSTALL.contains(
"[[ -n ${LESAVKA_ALLOW_GADGET_RESET:-} ]] || [[ \"$FORCE_GADGET_REBUILD\" == \"1\" ]]"
),
"LESAVKA_ALLOW_GADGET_RESET must not itself trigger a hard UDC detach/rebind" "LESAVKA_ALLOW_GADGET_RESET must not itself trigger a hard UDC detach/rebind"
); );
assert!( assert!(