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]]
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",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.21.10"
version = "0.21.11"
edition = "2024"
[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::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() {

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]
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::<&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]
#[serial]
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,
diagnostics_network,
diagnostics_process,
disconnect_cooldown_until,
next_power_probe,
next_calibration_probe,
next_upstream_sync_probe,

View File

@ -32,6 +32,7 @@ struct ActivationContext {
caps_request_in_flight: Rc<Cell<bool>>,
diagnostics_network: Rc<RefCell<NetworkTelemetry>>,
diagnostics_process: Rc<RefCell<ProcessCpuSampler>>,
disconnect_cooldown_until: Rc<Cell<Option<Instant>>>,
next_power_probe: Rc<Cell<Instant>>,
next_calibration_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 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,

View File

@ -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<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))]
fn usb_audio_kernel_support_missing() -> bool {
Command::new("modinfo")

View File

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

View File

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

View File

@ -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
}

View File

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

View File

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

View File

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

View File

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