media: protect upstream epoch recovery

This commit is contained in:
Brad Stein 2026-05-09 17:41:39 -03:00
parent a75463669f
commit e7a6d8f288
15 changed files with 183 additions and 27 deletions

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.21.10" version = "0.21.11"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.21.10" version = "0.21.11"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.21.10" version = "0.21.11"
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.21.10" version = "0.21.11"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -1,6 +1,29 @@
use super::apply_preview_profiles; use super::{apply_preview_profiles, disconnect_cooldown_remaining, disconnect_cooldown_status};
use crate::launcher::preview::{LauncherPreview, PreviewSurface}; use crate::launcher::preview::{LauncherPreview, PreviewSurface};
use crate::launcher::state::{CaptureSizePreset, FeedSourcePreset, LauncherState}; use crate::launcher::state::{CaptureSizePreset, FeedSourcePreset, LauncherState};
use std::time::{Duration, Instant};
#[test]
/// Keeps `disconnect_cooldown_remaining_expires_cleanly` explicit because the connect-time audio epoch refresh should not be interrupted by an eager disconnect click.
/// Inputs are a synthetic monotonic clock and cooldown deadline; output is either the remaining guard duration or no guard after expiry.
fn disconnect_cooldown_remaining_expires_cleanly() {
let now = Instant::now();
assert_eq!(disconnect_cooldown_remaining(now, None), None);
assert_eq!(disconnect_cooldown_remaining(now, Some(now)), None);
assert_eq!(
disconnect_cooldown_remaining(now, Some(now + Duration::from_secs(2))),
Some(Duration::from_secs(2))
);
}
#[test]
/// Keeps `disconnect_cooldown_status_names_audio_refresh` explicit because users need the disabled Disconnect button to explain the protective auto-heal window.
/// Inputs are the remaining cooldown duration; output is the user-visible status text.
fn disconnect_cooldown_status_names_audio_refresh() {
let status = disconnect_cooldown_status(Duration::from_millis(1900));
assert!(status.contains("audio is refreshing"));
assert!(status.contains("disconnect unlocks in 2s"));
}
#[test] #[test]
fn fresh_preview_bootstrap_is_overridden_by_launcher_state_profiles() { fn fresh_preview_bootstrap_is_overridden_by_launcher_state_profiles() {

View File

@ -24,6 +24,13 @@ fn present_and_settle(window: &gtk::ApplicationWindow) {
} }
} }
fn rail_button_text(button: &gtk::Button) -> Option<String> {
button
.child()
.and_then(|child| child.downcast::<gtk::Label>().ok())
.map(|label| label.text().to_string())
}
#[test] #[test]
fn local_test_detail_mentions_idle_and_running_modes() { fn local_test_detail_mentions_idle_and_running_modes() {
assert!(local_test_detail(false, false, false, false).contains("idle")); assert!(local_test_detail(false, false, false, false).contains("idle"));
@ -258,6 +265,63 @@ fn launcher_shell_installs_native_window_chrome() {
assert!(titlebar.shows_title_buttons()); assert!(titlebar.shows_title_buttons());
} }
#[gtk::test]
#[serial]
fn recovery_buttons_use_upstream_device_labels() {
if gtk::gdk::Display::default().is_none() {
return;
}
let app = gtk::Application::builder()
.application_id("dev.lesavka.test-recovery-labels")
.build();
let _ = app.register(None::<&gtk::gio::Cancellable>);
let view = build_launcher_view(
&app,
"http://127.0.0.1:50051",
&DeviceCatalog::default(),
&LauncherState::new(),
);
assert_eq!(
rail_button_text(&view.widgets.usb_recover_button),
Some("HID".to_string())
);
assert_eq!(
rail_button_text(&view.widgets.uac_recover_button),
Some("Audio".to_string())
);
assert_eq!(
rail_button_text(&view.widgets.uvc_recover_button),
Some("Video".to_string())
);
assert!(
view.widgets
.usb_recover_button
.tooltip_text()
.as_deref()
.unwrap_or_default()
.contains("keyboard/mouse HID")
);
assert!(
view.widgets
.uac_recover_button
.tooltip_text()
.as_deref()
.unwrap_or_default()
.contains("bundled mic+camera")
);
assert!(
view.widgets
.uvc_recover_button
.tooltip_text()
.as_deref()
.unwrap_or_default()
.contains("virtual webcam spool")
);
}
#[gtk::test] #[gtk::test]
#[serial] #[serial]
fn diagnostics_and_log_popouts_install_native_window_chrome() { fn diagnostics_and_log_popouts_install_native_window_chrome() {

View File

@ -155,6 +155,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
caps_request_in_flight, caps_request_in_flight,
diagnostics_network, diagnostics_network,
diagnostics_process, diagnostics_process,
disconnect_cooldown_until,
next_power_probe, next_power_probe,
next_calibration_probe, next_calibration_probe,
next_upstream_sync_probe, next_upstream_sync_probe,

View File

@ -32,6 +32,7 @@ struct ActivationContext {
caps_request_in_flight: Rc<Cell<bool>>, caps_request_in_flight: Rc<Cell<bool>>,
diagnostics_network: Rc<RefCell<NetworkTelemetry>>, diagnostics_network: Rc<RefCell<NetworkTelemetry>>,
diagnostics_process: Rc<RefCell<ProcessCpuSampler>>, diagnostics_process: Rc<RefCell<ProcessCpuSampler>>,
disconnect_cooldown_until: Rc<Cell<Option<Instant>>>,
next_power_probe: Rc<Cell<Instant>>, next_power_probe: Rc<Cell<Instant>>,
next_calibration_probe: Rc<Cell<Instant>>, next_calibration_probe: Rc<Cell<Instant>>,
next_upstream_sync_probe: Rc<Cell<Instant>>, next_upstream_sync_probe: Rc<Cell<Instant>>,

View File

@ -121,6 +121,7 @@
let caps_request_in_flight = Rc::new(Cell::new(false)); let caps_request_in_flight = Rc::new(Cell::new(false));
let diagnostics_network = Rc::new(RefCell::new(NetworkTelemetry::default())); let diagnostics_network = Rc::new(RefCell::new(NetworkTelemetry::default()));
let diagnostics_process = Rc::new(RefCell::new(ProcessCpuSampler::new())); let diagnostics_process = Rc::new(RefCell::new(ProcessCpuSampler::new()));
let disconnect_cooldown_until = Rc::new(Cell::new(None));
let next_power_probe = let next_power_probe =
Rc::new(Cell::new(Instant::now() + Duration::from_millis(500))); Rc::new(Cell::new(Instant::now() + Duration::from_millis(500)));
let next_calibration_probe = let next_calibration_probe =
@ -173,6 +174,7 @@
caps_request_in_flight, caps_request_in_flight,
diagnostics_network, diagnostics_network,
diagnostics_process, diagnostics_process,
disconnect_cooldown_until,
next_power_probe, next_power_probe,
next_calibration_probe, next_calibration_probe,
next_upstream_sync_probe, next_upstream_sync_probe,

View File

@ -33,6 +33,22 @@ enum ClipboardMessage {
#[cfg(not(coverage))] #[cfg(not(coverage))]
const NETWORK_TELEMETRY_WINDOW: Duration = Duration::from_secs(8); const NETWORK_TELEMETRY_WINDOW: Duration = Duration::from_secs(8);
#[cfg(not(coverage))]
const UPSTREAM_EPOCH_HEAL_DISCONNECT_COOLDOWN: Duration = Duration::from_secs(3);
#[cfg(not(coverage))]
fn disconnect_cooldown_remaining(now: Instant, until: Option<Instant>) -> Option<Duration> {
until
.and_then(|deadline| deadline.checked_duration_since(now))
.filter(|remaining| !remaining.is_zero())
}
#[cfg(not(coverage))]
fn disconnect_cooldown_status(remaining: Duration) -> String {
let seconds = remaining.as_millis().saturating_add(999) / 1_000;
format!("Upstream audio is refreshing; disconnect unlocks in {seconds}s.")
}
#[cfg(not(coverage))] #[cfg(not(coverage))]
fn usb_audio_kernel_support_missing() -> bool { fn usb_audio_kernel_support_missing() -> bool {
Command::new("modinfo") Command::new("modinfo")

View File

@ -17,6 +17,7 @@
let power_tx = power_tx.clone(); let power_tx = power_tx.clone();
let relay_tx = relay_tx.clone(); let relay_tx = relay_tx.clone();
let relay_request_in_flight = Rc::clone(&relay_request_in_flight); let relay_request_in_flight = Rc::clone(&relay_request_in_flight);
let disconnect_cooldown_until = Rc::clone(&disconnect_cooldown_until);
let popouts = Rc::clone(&popouts); let popouts = Rc::clone(&popouts);
let window = window.clone(); let window = window.clone();
let start_button = widgets.start_button.clone(); let start_button = widgets.start_button.clone();
@ -28,7 +29,17 @@
return; return;
} }
if child_proc.borrow().is_some() { if child_proc.borrow().is_some() {
if let Some(remaining) = disconnect_cooldown_remaining(
Instant::now(),
disconnect_cooldown_until.get(),
) {
widgets_handle
.status_label
.set_text(&disconnect_cooldown_status(remaining));
return;
}
stop_child_process(&child_proc); stop_child_process(&child_proc);
disconnect_cooldown_until.set(None);
let power_mode = { let power_mode = {
let mut state = state.borrow_mut(); let mut state = state.borrow_mut();
let _ = state.stop_remote(); let _ = state.stop_remote();

View File

@ -27,6 +27,7 @@
let next_diagnostics_probe = Rc::clone(&next_diagnostics_probe); let next_diagnostics_probe = Rc::clone(&next_diagnostics_probe);
let next_diagnostics_sample = Rc::clone(&next_diagnostics_sample); let next_diagnostics_sample = Rc::clone(&next_diagnostics_sample);
let preview_session_active = Rc::clone(&preview_session_active); let preview_session_active = Rc::clone(&preview_session_active);
let disconnect_cooldown_until = Rc::clone(&disconnect_cooldown_until);
let log_tx = log_tx.clone(); let log_tx = log_tx.clone();
let camera_preview_path = uplink_camera_preview_path(); let camera_preview_path = uplink_camera_preview_path();
let mic_level_path = uplink_mic_level_path(); let mic_level_path = uplink_mic_level_path();
@ -123,6 +124,9 @@
RelayMessage::Spawned(Ok(mut child)) => { RelayMessage::Spawned(Ok(mut child)) => {
attach_child_log_streams(&mut child, log_tx.clone()); attach_child_log_streams(&mut child, log_tx.clone());
*child_proc.borrow_mut() = Some(child); *child_proc.borrow_mut() = Some(child);
disconnect_cooldown_until.set(Some(
Instant::now() + UPSTREAM_EPOCH_HEAL_DISCONNECT_COOLDOWN,
));
{ {
let mut state = state.borrow_mut(); let mut state = state.borrow_mut();
state.set_server_available(true); state.set_server_available(true);
@ -397,6 +401,11 @@
let now = Instant::now(); let now = Instant::now();
let child_running = child_proc.borrow().is_some(); let child_running = child_proc.borrow().is_some();
let disconnect_cooldown =
disconnect_cooldown_remaining(now, disconnect_cooldown_until.get());
if child_running && disconnect_cooldown.is_none() {
disconnect_cooldown_until.set(None);
}
if now >= next_power_probe.get() if now >= next_power_probe.get()
&& !power_request_in_flight.get() && !power_request_in_flight.get()
@ -490,6 +499,14 @@
} }
refresh_launcher_ui(&widgets, &state.borrow(), child_running); refresh_launcher_ui(&widgets, &state.borrow(), child_running);
if child_running
&& let Some(remaining) = disconnect_cooldown
{
widgets.start_button.set_sensitive(false);
widgets
.start_button
.set_tooltip_text(Some(&disconnect_cooldown_status(remaining)));
}
refresh_test_buttons(&widgets, &mut tests.borrow_mut()); refresh_test_buttons(&widgets, &mut tests.borrow_mut());
glib::ControlFlow::Continue glib::ControlFlow::Continue
}); });

View File

@ -161,7 +161,7 @@
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: reopening HID handles and checking enumeration...", "Recover HID 1/3: reopening keyboard/mouse 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 || {
@ -172,20 +172,20 @@
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: HID reopened. Recover USB 3/3: watching chips for stable enumeration.", "Recover HID 2/3: handles reopened. Recover HID 3/3: watching chips for stable enumeration.",
); );
glib::ControlFlow::Break glib::ControlFlow::Break
} }
Ok(Err(err)) => { Ok(Err(err)) => {
widgets widgets
.status_label .status_label
.set_text(&format!("Recover USB failed: {err}")); .set_text(&format!("Recover HID failed: {err}"));
glib::ControlFlow::Break glib::ControlFlow::Break
} }
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
Err(std::sync::mpsc::TryRecvError::Disconnected) => { Err(std::sync::mpsc::TryRecvError::Disconnected) => {
widgets.status_label.set_text( widgets.status_label.set_text(
"Recover USB failed: relay stopped responding before completion.", "Recover HID failed: relay stopped responding before completion.",
); );
glib::ControlFlow::Break glib::ControlFlow::Break
} }
@ -202,7 +202,7 @@
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("Heal A/V 1/3: retiring the stale upstream media epoch cleanly..."); .set_text("Recover Audio 1/3: retiring the stale upstream audio epoch 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 = recover_uac_soft(&server_addr).map_err(|err| format!("{err:#}")); let result = recover_uac_soft(&server_addr).map_err(|err| format!("{err:#}"));
@ -212,20 +212,20 @@
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(
"Heal A/V 2/3: old epoch released. Heal A/V 3/3: client will reconnect without resetting USB or calibration.", "Recover Audio 2/3: old epoch released. Recover Audio 3/3: bundled media will reconnect without resetting USB or calibration.",
); );
glib::ControlFlow::Break glib::ControlFlow::Break
} }
Ok(Err(err)) => { Ok(Err(err)) => {
widgets widgets
.status_label .status_label
.set_text(&format!("Heal A/V failed: {err}")); .set_text(&format!("Recover Audio failed: {err}"));
glib::ControlFlow::Break glib::ControlFlow::Break
} }
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
Err(std::sync::mpsc::TryRecvError::Disconnected) => { Err(std::sync::mpsc::TryRecvError::Disconnected) => {
widgets.status_label.set_text( widgets.status_label.set_text(
"Heal A/V failed: relay stopped responding before completion.", "Recover Audio failed: relay stopped responding before completion.",
); );
glib::ControlFlow::Break glib::ControlFlow::Break
} }
@ -242,7 +242,7 @@
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: retiring the webcam spool pipeline safely..."); .set_text("Recover Video 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 = recover_uvc_soft(&server_addr).map_err(|err| format!("{err:#}")); let result = recover_uvc_soft(&server_addr).map_err(|err| format!("{err:#}"));
@ -252,20 +252,20 @@
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: webcam sink retired. Recover UVC 3/3: client will recreate the UVC spool on reconnect.", "Recover Video 2/3: webcam sink retired. Recover Video 3/3: client will recreate the UVC spool on reconnect.",
); );
glib::ControlFlow::Break glib::ControlFlow::Break
} }
Ok(Err(err)) => { Ok(Err(err)) => {
widgets widgets
.status_label .status_label
.set_text(&format!("Recover UVC failed: {err}")); .set_text(&format!("Recover Video failed: {err}"));
glib::ControlFlow::Break glib::ControlFlow::Break
} }
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
Err(std::sync::mpsc::TryRecvError::Disconnected) => { Err(std::sync::mpsc::TryRecvError::Disconnected) => {
widgets.status_label.set_text( widgets.status_label.set_text(
"Recover UVC failed: relay stopped responding before completion.", "Recover Video failed: relay stopped responding before completion.",
); );
glib::ControlFlow::Break glib::ControlFlow::Break
} }

View File

@ -28,9 +28,10 @@
connection_body.append(&relay_grid); connection_body.append(&relay_grid);
let recovery_heading = gtk::Label::new(Some("Recover")); let recovery_heading = gtk::Label::new(Some("Recover\nUpstream"));
recovery_heading.add_css_class("subgroup-title"); recovery_heading.add_css_class("subgroup-title");
recovery_heading.set_halign(gtk::Align::Start); recovery_heading.set_halign(gtk::Align::Start);
recovery_heading.set_justify(gtk::Justification::Center);
let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
recovery_row.set_hexpand(true); recovery_row.set_hexpand(true);
recovery_heading.set_width_chars(RELAY_SUBGROUP_LABEL_WIDTH); recovery_heading.set_width_chars(RELAY_SUBGROUP_LABEL_WIDTH);
@ -39,16 +40,16 @@
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( let usb_recover_button = rail_button(
"USB", "HID",
"Soft recovery: reopen HID and verify enumeration without detaching the USB gadget.", "Soft recovery: reopen keyboard/mouse HID handles and verify enumeration without detaching the USB gadget.",
); );
let uac_recover_button = rail_button( let uac_recover_button = rail_button(
"Heal A/V", "Audio",
"Soft recovery: retire the stale upstream A/V epoch so bundled mic+camera reconnect without resetting USB or changing calibration.", "Soft recovery: retire the stale upstream audio epoch so bundled mic+camera reconnect without resetting USB or changing calibration.",
); );
let uvc_recover_button = rail_button( let uvc_recover_button = rail_button(
"UVC", "Video",
"Soft recovery: retire the webcam spool pipeline so video reconnects without restarting the gadget.", "Soft recovery: retire the virtual 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);

View File

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

View File

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

View File

@ -169,11 +169,31 @@ fn runtime_soft_microphone_recovery_cycles_only_the_microphone_generation() {
with_clean_offset_env(|| { with_clean_offset_env(|| {
let runtime = UpstreamMediaRuntime::new(); let runtime = UpstreamMediaRuntime::new();
let camera = runtime.activate_camera(); let camera = runtime.activate_camera();
let microphone = runtime.activate_microphone();
runtime.soft_recover_microphone(); runtime.soft_recover_microphone();
assert!(runtime.is_camera_active(camera.generation)); assert!(runtime.is_camera_active(camera.generation));
assert!(!runtime.is_microphone_active(1)); assert!(!runtime.is_microphone_active(microphone.generation));
runtime.close_camera(camera.generation);
});
}
#[test]
/// Keeps `runtime_soft_microphone_recovery_preserves_calibration_offsets` explicit because the manual and automatic audio healing paths must not masquerade as A/V calibration.
/// Inputs are active upstream leases plus non-default playout offsets; output keeps the offsets and camera generation intact while retiring only the microphone generation.
fn runtime_soft_microphone_recovery_preserves_calibration_offsets() {
with_clean_offset_env(|| {
let runtime = UpstreamMediaRuntime::new();
let camera = runtime.activate_camera();
let microphone = runtime.activate_microphone();
runtime.set_playout_offsets(135_090, 7_000);
runtime.soft_recover_microphone();
assert_eq!(runtime.playout_offsets(), (135_090, 7_000));
assert!(runtime.is_camera_active(camera.generation));
assert!(!runtime.is_microphone_active(microphone.generation));
runtime.close_camera(camera.generation); runtime.close_camera(camera.generation);
}); });
} }