media: protect upstream epoch recovery
This commit is contained in:
parent
a75463669f
commit
e7a6d8f288
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.21.10"
|
||||
version = "0.21.11"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -24,6 +24,13 @@ fn present_and_settle(window: >k::ApplicationWindow) {
|
||||
}
|
||||
}
|
||||
|
||||
fn rail_button_text(button: >k::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::<>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() {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>>,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.21.10"
|
||||
version = "0.21.11"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.21.10"
|
||||
version = "0.21.11"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user