From e7a6d8f288d60677bed59acb7cf7176040f63435 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sat, 9 May 2026 17:41:39 -0300 Subject: [PATCH] media: protect upstream epoch recovery --- Cargo.lock | 6 +- client/Cargo.toml | 2 +- .../src/launcher/tests/ui_preview_profiles.rs | 25 +++++++- client/src/launcher/tests/ui_runtime.rs | 64 +++++++++++++++++++ client/src/launcher/ui.rs | 1 + client/src/launcher/ui/activation_context.rs | 1 + client/src/launcher/ui/activation_setup.rs | 2 + .../launcher/ui/message_and_network_state.rs | 16 +++++ .../src/launcher/ui/relay_input_bindings.rs | 11 ++++ client/src/launcher/ui/runtime_poll.rs | 17 +++++ .../launcher/ui/utility_button_bindings.rs | 24 +++---- .../ui_components/build_operations_rail.rs | 15 +++-- common/Cargo.toml | 2 +- server/Cargo.toml | 2 +- .../src/upstream_media_runtime/tests/mod.rs | 22 ++++++- 15 files changed, 183 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d7a8d5e..9332684 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.21.10" +version = "0.21.11" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.21.10" +version = "0.21.11" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.21.10" +version = "0.21.11" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index fed65f9..dd6fd6f 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.21.10" +version = "0.21.11" edition = "2024" [dependencies] diff --git a/client/src/launcher/tests/ui_preview_profiles.rs b/client/src/launcher/tests/ui_preview_profiles.rs index 01cfc4a..84731aa 100644 --- a/client/src/launcher/tests/ui_preview_profiles.rs +++ b/client/src/launcher/tests/ui_preview_profiles.rs @@ -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::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] fn fresh_preview_bootstrap_is_overridden_by_launcher_state_profiles() { diff --git a/client/src/launcher/tests/ui_runtime.rs b/client/src/launcher/tests/ui_runtime.rs index d8f5512..47a76e2 100644 --- a/client/src/launcher/tests/ui_runtime.rs +++ b/client/src/launcher/tests/ui_runtime.rs @@ -24,6 +24,13 @@ fn present_and_settle(window: >k::ApplicationWindow) { } } +fn rail_button_text(button: >k::Button) -> Option { + button + .child() + .and_then(|child| child.downcast::().ok()) + .map(|label| label.text().to_string()) +} + #[test] fn local_test_detail_mentions_idle_and_running_modes() { 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()); } +#[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::<>k::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] #[serial] fn diagnostics_and_log_popouts_install_native_window_chrome() { diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 958531a..ccea78c 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -155,6 +155,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { caps_request_in_flight, diagnostics_network, diagnostics_process, + disconnect_cooldown_until, next_power_probe, next_calibration_probe, next_upstream_sync_probe, diff --git a/client/src/launcher/ui/activation_context.rs b/client/src/launcher/ui/activation_context.rs index 23af8e3..13ed45a 100644 --- a/client/src/launcher/ui/activation_context.rs +++ b/client/src/launcher/ui/activation_context.rs @@ -32,6 +32,7 @@ struct ActivationContext { caps_request_in_flight: Rc>, diagnostics_network: Rc>, diagnostics_process: Rc>, + disconnect_cooldown_until: Rc>>, next_power_probe: Rc>, next_calibration_probe: Rc>, next_upstream_sync_probe: Rc>, diff --git a/client/src/launcher/ui/activation_setup.rs b/client/src/launcher/ui/activation_setup.rs index f85149e..187f0f2 100644 --- a/client/src/launcher/ui/activation_setup.rs +++ b/client/src/launcher/ui/activation_setup.rs @@ -121,6 +121,7 @@ let caps_request_in_flight = Rc::new(Cell::new(false)); let diagnostics_network = Rc::new(RefCell::new(NetworkTelemetry::default())); let diagnostics_process = Rc::new(RefCell::new(ProcessCpuSampler::new())); + let disconnect_cooldown_until = Rc::new(Cell::new(None)); let next_power_probe = Rc::new(Cell::new(Instant::now() + Duration::from_millis(500))); let next_calibration_probe = @@ -173,6 +174,7 @@ caps_request_in_flight, diagnostics_network, diagnostics_process, + disconnect_cooldown_until, next_power_probe, next_calibration_probe, next_upstream_sync_probe, diff --git a/client/src/launcher/ui/message_and_network_state.rs b/client/src/launcher/ui/message_and_network_state.rs index 4729241..f6bf609 100644 --- a/client/src/launcher/ui/message_and_network_state.rs +++ b/client/src/launcher/ui/message_and_network_state.rs @@ -33,6 +33,22 @@ enum ClipboardMessage { #[cfg(not(coverage))] 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) -> Option { + 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))] fn usb_audio_kernel_support_missing() -> bool { Command::new("modinfo") diff --git a/client/src/launcher/ui/relay_input_bindings.rs b/client/src/launcher/ui/relay_input_bindings.rs index 2e6579f..f32375e 100644 --- a/client/src/launcher/ui/relay_input_bindings.rs +++ b/client/src/launcher/ui/relay_input_bindings.rs @@ -17,6 +17,7 @@ let power_tx = power_tx.clone(); let relay_tx = relay_tx.clone(); 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 window = window.clone(); let start_button = widgets.start_button.clone(); @@ -28,7 +29,17 @@ return; } 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); + disconnect_cooldown_until.set(None); let power_mode = { let mut state = state.borrow_mut(); let _ = state.stop_remote(); diff --git a/client/src/launcher/ui/runtime_poll.rs b/client/src/launcher/ui/runtime_poll.rs index c3d5166..ef3ed1d 100644 --- a/client/src/launcher/ui/runtime_poll.rs +++ b/client/src/launcher/ui/runtime_poll.rs @@ -27,6 +27,7 @@ let next_diagnostics_probe = Rc::clone(&next_diagnostics_probe); let next_diagnostics_sample = Rc::clone(&next_diagnostics_sample); 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 camera_preview_path = uplink_camera_preview_path(); let mic_level_path = uplink_mic_level_path(); @@ -123,6 +124,9 @@ RelayMessage::Spawned(Ok(mut child)) => { attach_child_log_streams(&mut child, log_tx.clone()); *child_proc.borrow_mut() = Some(child); + disconnect_cooldown_until.set(Some( + Instant::now() + UPSTREAM_EPOCH_HEAL_DISCONNECT_COOLDOWN, + )); { let mut state = state.borrow_mut(); state.set_server_available(true); @@ -397,6 +401,11 @@ let now = Instant::now(); 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() && !power_request_in_flight.get() @@ -490,6 +499,14 @@ } 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()); glib::ControlFlow::Continue }); diff --git a/client/src/launcher/ui/utility_button_bindings.rs b/client/src/launcher/ui/utility_button_bindings.rs index 5fab017..97d9d9d 100644 --- a/client/src/launcher/ui/utility_button_bindings.rs +++ b/client/src/launcher/ui/utility_button_bindings.rs @@ -161,7 +161,7 @@ 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: reopening HID handles and checking enumeration...", + "Recover HID 1/3: reopening keyboard/mouse handles and checking enumeration...", ); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { @@ -172,20 +172,20 @@ glib::timeout_add_local(Duration::from_millis(100), move || match rx.try_recv() { Ok(Ok(())) => { 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 } Ok(Err(err)) => { widgets .status_label - .set_text(&format!("Recover USB failed: {err}")); + .set_text(&format!("Recover HID failed: {err}")); glib::ControlFlow::Break } Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, Err(std::sync::mpsc::TryRecvError::Disconnected) => { widgets.status_label.set_text( - "Recover USB failed: relay stopped responding before completion.", + "Recover HID failed: relay stopped responding before completion.", ); glib::ControlFlow::Break } @@ -202,7 +202,7 @@ let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); widgets_for_click .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(); std::thread::spawn(move || { 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() { Ok(Ok(())) => { 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 } Ok(Err(err)) => { widgets .status_label - .set_text(&format!("Heal A/V failed: {err}")); + .set_text(&format!("Recover Audio failed: {err}")); glib::ControlFlow::Break } Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, Err(std::sync::mpsc::TryRecvError::Disconnected) => { 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 } @@ -242,7 +242,7 @@ let server_addr = selected_server_addr(&server_entry, server_addr_fallback.as_ref()); widgets_for_click .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(); std::thread::spawn(move || { 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() { Ok(Ok(())) => { 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 } Ok(Err(err)) => { widgets .status_label - .set_text(&format!("Recover UVC failed: {err}")); + .set_text(&format!("Recover Video failed: {err}")); glib::ControlFlow::Break } Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, Err(std::sync::mpsc::TryRecvError::Disconnected) => { widgets.status_label.set_text( - "Recover UVC failed: relay stopped responding before completion.", + "Recover Video failed: relay stopped responding before completion.", ); glib::ControlFlow::Break } diff --git a/client/src/launcher/ui_components/build_operations_rail.rs b/client/src/launcher/ui_components/build_operations_rail.rs index d48c3a3..003c397 100644 --- a/client/src/launcher/ui_components/build_operations_rail.rs +++ b/client/src/launcher/ui_components/build_operations_rail.rs @@ -28,9 +28,10 @@ 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.set_halign(gtk::Align::Start); + recovery_heading.set_justify(gtk::Justification::Center); let recovery_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); recovery_row.set_hexpand(true); recovery_heading.set_width_chars(RELAY_SUBGROUP_LABEL_WIDTH); @@ -39,16 +40,16 @@ recovery_buttons.set_hexpand(true); recovery_buttons.set_homogeneous(true); let usb_recover_button = rail_button( - "USB", - "Soft recovery: reopen HID and verify enumeration without detaching the USB gadget.", + "HID", + "Soft recovery: reopen keyboard/mouse HID handles and verify enumeration without detaching the USB gadget.", ); let uac_recover_button = rail_button( - "Heal A/V", - "Soft recovery: retire the stale upstream A/V epoch so bundled mic+camera reconnect without resetting USB or changing calibration.", + "Audio", + "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( - "UVC", - "Soft recovery: retire the webcam spool pipeline so video reconnects without restarting the gadget.", + "Video", + "Soft recovery: retire the virtual webcam spool pipeline so video reconnects without restarting the gadget.", ); recovery_buttons.append(&usb_recover_button); recovery_buttons.append(&uac_recover_button); diff --git a/common/Cargo.toml b/common/Cargo.toml index 97e37d2..3020764 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.21.10" +version = "0.21.11" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index 16bd7c3..fde60ef 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.21.10" +version = "0.21.11" edition = "2024" autobins = false diff --git a/server/src/upstream_media_runtime/tests/mod.rs b/server/src/upstream_media_runtime/tests/mod.rs index 68eb59e..9658452 100644 --- a/server/src/upstream_media_runtime/tests/mod.rs +++ b/server/src/upstream_media_runtime/tests/mod.rs @@ -169,11 +169,31 @@ fn runtime_soft_microphone_recovery_cycles_only_the_microphone_generation() { with_clean_offset_env(|| { let runtime = UpstreamMediaRuntime::new(); let camera = runtime.activate_camera(); + let microphone = runtime.activate_microphone(); runtime.soft_recover_microphone(); 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); }); }