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]]
name = "lesavka_client"
version = "0.16.7"
version = "0.16.8"
dependencies = [
"anyhow",
"async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]]
name = "lesavka_common"
version = "0.16.7"
version = "0.16.8"
dependencies = [
"anyhow",
"base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]]
name = "lesavka_server"
version = "0.16.7"
version = "0.16.8"
dependencies = [
"anyhow",
"base64",

View File

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

View File

@ -16,17 +16,24 @@ enum CommandKind {
Auto,
On,
Off,
RecoverUsb,
RecoverUac,
RecoverUvc,
ResetUsb,
}
impl CommandKind {
/// Parse the intentionally small CLI vocabulary into safe relay actions.
fn parse(value: &str) -> Option<Self> {
match value {
"status" | "get" => Some(Self::Status),
"auto" => Some(Self::Auto),
"on" | "force-on" => Some(Self::On),
"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,
}
}
@ -45,7 +52,7 @@ enum ParseOutcome {
}
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>
@ -103,7 +110,11 @@ fn parse_args() -> Result<ParseOutcome> {
fn capture_power_request(command: CommandKind) -> Option<SetCapturePowerRequest> {
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::On => (true, CapturePowerCommand::ForceOn),
CommandKind::Off => (false, CapturePowerCommand::ForceOff),
@ -160,7 +171,11 @@ async fn main() -> Result<()> {
CommandKind::Auto => "setting capture power to auto",
CommandKind::On => "forcing capture power on",
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
.set_capture_power(Request::new(request))
@ -177,6 +192,33 @@ async fn main() -> Result<()> {
.await
.context("querying capture power state")?
.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 => {
let reply = client
.reset_usb(Request::new(Empty {}))
@ -207,6 +249,7 @@ mod tests {
use lesavka_common::lesavka::CapturePowerState;
#[test]
/// Verifies safe recovery commands stay separate from explicit hard reset.
fn command_aliases_parse_to_stable_actions() {
assert_eq!(CommandKind::parse("status"), 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("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)
);
assert_eq!(CommandKind::parse("wat"), None);
@ -272,6 +327,7 @@ mod tests {
}
#[test]
/// Keeps status/read commands from accidentally mutating capture power.
fn mutating_commands_map_to_capture_power_requests() {
let auto = capture_power_request(CommandKind::Auto).expect("auto request");
assert!(!auto.enabled);
@ -286,6 +342,9 @@ mod tests {
assert_eq!(off.command, CapturePowerCommand::ForceOff as i32);
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());
}

View File

@ -61,3 +61,51 @@ include!("camera/encoder_selection.rs");
include!("camera/source_description.rs");
include!("camera/preview_tap.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),
);
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 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 (width, height, fps) = resolved_capture_profile(cfg);
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 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) {
if packet_index == 0 {
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 {
let mut client = connect(server_addr).await?;
let reply = client
.reset_usb(Request::new(Empty {}))
.await
.context("requesting USB gadget reset")?
.into_inner();
let request = Request::new(Empty {});
let response = match kind {
RecoveryKind::Usb => client
.recover_usb(request)
.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 {
Ok(())
} 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(
&self,
_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 lesavka_common::lesavka::{
AudioPacket, CalibrationRequest, CalibrationState, CapturePowerState, Empty, KeyboardReport,
@ -104,6 +107,30 @@ impl Relay for UtilityRelay {
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(
&self,
_request: Request<Empty>,
@ -183,13 +210,25 @@ fn recover_usb_action_reports_relay_success_and_failure() {
let relay = UtilityRelay::new(true);
let reset_count = Arc::clone(&relay.reset_count);
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);
let relay = UtilityRelay::new(false);
let reset_count = Arc::clone(&relay.reset_count);
let (_rt, addr) = serve(relay);
let err = reset_usb_gadget(&addr).expect_err("reset failure");
assert!(format!("{err:#}").contains("relay reported USB reset failure"));
let err = recover_usb_soft(&addr).expect_err("soft USB recovery failure");
assert!(format!("{err:#}").contains("relay reported soft USB recovery failure"));
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::launcher_clipboard_control_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::state::{
BreakoutSizePreset, CalibrationStatus, CapturePowerStatus, CaptureSizePreset,

View File

@ -161,18 +161,18 @@
widgets.usb_recover_button.connect_clicked(move |_| {
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
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();
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 widgets = widgets_for_click.clone();
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
Ok(Ok(())) => {
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
}
@ -202,17 +202,17 @@
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
widgets_for_click
.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();
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 widgets = widgets_for_click.clone();
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
Ok(Ok(())) => {
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
}
@ -242,17 +242,17 @@
let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref());
widgets_for_click
.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();
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 widgets = widgets_for_click.clone();
glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() {
Ok(Ok(())) => {
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
}

View File

@ -38,9 +38,18 @@
let recovery_buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
recovery_buttons.set_hexpand(true);
recovery_buttons.set_homogeneous(true);
let usb_recover_button = rail_button("USB", "Re-enumerate remote USB gadget.");
let uac_recover_button = rail_button("UAC", "Rebuild remote USB audio function.");
let uvc_recover_button = rail_button("UVC", "Rebuild remote USB webcam function.");
let usb_recover_button = rail_button(
"USB",
"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(&uac_recover_button);
recovery_buttons.append(&uvc_recover_button);

View File

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

View File

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

View File

@ -457,6 +457,15 @@ if [[ -z $DISABLE_UAC ]]; then
echo adaptive >"$U/c_sync" 2>/dev/null || true
# Optional: allocate a few extra request buffers
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
log "🔇 UAC2 disabled (LESAVKA_DISABLE_UAC set)"
fi

View File

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

View File

@ -116,6 +116,25 @@ impl CameraRuntime {
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)]
#[cfg(not(coverage))]
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 AudioStream = Pin<Box<dyn Stream<Item = Result<AudioPacket, Status>> + Send>>;
type ResetReply = Result<Response<ResetUsbReply>, Status>;
fn hid_endpoint(index: u8) -> String {
std::env::var("LESAVKA_HID_DIR")

View File

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

View File

@ -271,6 +271,27 @@ impl Relay for Handler {
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(
&self,
_req: Request<Empty>,

View File

@ -1,4 +1,53 @@
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(
&self,
req: Request<PasteRequest>,

View File

@ -105,6 +105,17 @@ impl UpstreamMediaRuntime {
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) {
let mut state = self
.state

View File

@ -189,3 +189,37 @@ fn close_ignores_superseded_generation_values() {
let next = runtime.activate_camera();
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("build_camera_preview_pipeline(&device, mode)"));
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_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("press Stop to finish."));
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 2/3: relay acknowledged reset."));
assert!(UI_SRC.contains("recover_usb_soft(&server_addr)"));
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]

View File

@ -22,10 +22,24 @@ mod relayctl_binary {
assert_eq!(CommandKind::parse("reset-usb"), Some(CommandKind::ResetUsb));
assert_eq!(
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)
);
assert_eq!(CommandKind::parse("bad"), None);
assert!(usage().contains("lesavka-relayctl"));
assert!(usage().contains("recover-uac"));
assert!(usage().contains("recover-uvc"));
}
#[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"
);
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"
);
assert!(