From 5eb984ce0813301c8b76a7799c006b210f4134ac Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Tue, 28 Apr 2026 20:28:36 -0300 Subject: [PATCH] fix(server): avoid auto gadget cycles with external uvc --- Cargo.lock | 6 +-- client/Cargo.toml | 2 +- common/Cargo.toml | 2 +- scripts/install/server.sh | 19 ++++++-- server/Cargo.toml | 2 +- server/src/main/handler_startup.rs | 13 ++++-- server/src/runtime_support/hid_recovery.rs | 18 +++++++- server/src/tests/runtime_support.rs | 43 +++++++++++++++++-- .../tests/server_install_script_contract.rs | 12 ++++++ 9 files changed, 97 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ae22958..62e6cce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.14.42" +version = "0.14.43" dependencies = [ "anyhow", "async-stream", @@ -1676,7 +1676,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.14.42" +version = "0.14.43" dependencies = [ "anyhow", "base64", @@ -1688,7 +1688,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.14.42" +version = "0.14.43" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 27cb5b0..f922fc3 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.14.42" +version = "0.14.43" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index dfc00a8..d9bc8c4 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.14.42" +version = "0.14.43" edition = "2024" build = "build.rs" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 6d17b84..7fdcd39 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -315,11 +315,23 @@ wait_for_unit_running() { } validate_server_ready() { - local bind_addr + local bind_addr port bind_addr=$(server_bind_addr) + port=$(server_bind_port) || { + echo "❌ could not parse LESAVKA_SERVER_BIND_ADDR='${bind_addr}' while validating server readiness." >&2 + return 1 + } if wait_for_unit_running lesavka-server; then - echo "✅ lesavka-server is active and running on ${bind_addr}." - return 0 + for _ in {1..50}; do + if list_server_listener_inodes_proc "$port" | grep -q .; then + echo "✅ lesavka-server is active and listening on ${bind_addr}." + return 0 + fi + sleep 0.2 + done + echo "❌ lesavka-server reached active/running state but did not open TCP :${port}." >&2 + sudo journalctl -b -u lesavka-server -n 80 --no-pager >&2 || true + return 1 fi echo "❌ lesavka-server failed to reach active/running state on ${bind_addr}." >&2 @@ -850,7 +862,6 @@ Environment=LESAVKA_EYE_MIN_FPS=12 Environment=LESAVKA_EYE_FPS=20 Environment=LESAVKA_MIC_INIT_ATTEMPTS=5 Environment=LESAVKA_MIC_INIT_DELAY_MS=250 -Environment=LESAVKA_ALLOW_GADGET_CYCLE=1 Environment=LESAVKA_SERVER_LOG_PATH=/var/log/lesavka/server.log EnvironmentFile=-/etc/lesavka/uvc.env EnvironmentFile=-/etc/lesavka/server.env diff --git a/server/Cargo.toml b/server/Cargo.toml index 382e392..cb5c5be 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.14.42" +version = "0.14.43" edition = "2024" autobins = false diff --git a/server/src/main/handler_startup.rs b/server/src/main/handler_startup.rs index 3fd3999..6a15516 100644 --- a/server/src/main/handler_startup.rs +++ b/server/src/main/handler_startup.rs @@ -22,9 +22,15 @@ impl Handler { #[cfg(not(coverage))] { if !runtime_support::allow_gadget_cycle() { - info!( - "🔒 gadget cycle disabled at startup (set LESAVKA_ALLOW_GADGET_CYCLE=1 to enable)" - ); + if runtime_support::external_uvc_helper_owns_gadget() { + info!( + "🔒 gadget cycle disabled at startup because external UVC helper owns the gadget" + ); + } else { + info!( + "🔒 gadget cycle disabled at startup (set LESAVKA_ALLOW_GADGET_CYCLE=1 to enable)" + ); + } } info!("🛠️ opening HID endpoints …"); } @@ -127,5 +133,4 @@ impl Handler { .max(self.active_eye_source_count().await); state } - } diff --git a/server/src/runtime_support/hid_recovery.rs b/server/src/runtime_support/hid_recovery.rs index 04183d4..2a52e2d 100644 --- a/server/src/runtime_support/hid_recovery.rs +++ b/server/src/runtime_support/hid_recovery.rs @@ -155,15 +155,29 @@ pub fn hid_endpoint_open_is_temporarily_unavailable(code: Option) -> bool { ) } +/// Check whether the standalone UVC helper owns the gadget device. +/// +/// Inputs: process environment. +/// Outputs: `true` when UVC is enabled and supervised by systemd instead of +/// the server process. +/// Why: automatic whole-gadget resets can wedge configfs if they race the UVC +/// helper's open video-output node. +#[must_use] +pub fn external_uvc_helper_owns_gadget() -> bool { + std::env::var("LESAVKA_UVC_EXTERNAL").is_ok() && std::env::var("LESAVKA_DISABLE_UVC").is_err() +} + /// Check whether gadget auto-recovery is enabled. /// /// Inputs: none. /// Outputs: `true` only when the explicit recovery opt-in env var is present. -/// Why: cycling the whole USB gadget can be disruptive, so operators must -/// choose that behavior deliberately on each deployment. +/// Why: cycling the whole USB gadget can be disruptive, and it is especially +/// unsafe while the external UVC helper owns the gadget video node. #[must_use] pub fn allow_gadget_cycle() -> bool { std::env::var("LESAVKA_ALLOW_GADGET_CYCLE").is_ok() + && (!external_uvc_helper_owns_gadget() + || std::env::var("LESAVKA_ALLOW_EXTERNAL_UVC_GADGET_CYCLE").is_ok()) } /// Return whether a HID write error should trigger recovery. diff --git a/server/src/tests/runtime_support.rs b/server/src/tests/runtime_support.rs index b72f944..d77d6da 100644 --- a/server/src/tests/runtime_support.rs +++ b/server/src/tests/runtime_support.rs @@ -1,8 +1,9 @@ use super::{ - allow_gadget_cycle, detect_uac_card_candidates, init_tracing, next_stream_id, - open_ear_with_retry, open_hid_if_ready, open_with_retry, parse_uac_named_card_candidates, - parse_uac_numeric_card_ids, parse_uac_pcm_candidates, preferred_uac_device_candidates, - push_audio_candidate, push_audio_candidate_family, should_recover_hid_error, write_hid_report, + allow_gadget_cycle, detect_uac_card_candidates, external_uvc_helper_owns_gadget, init_tracing, + next_stream_id, open_ear_with_retry, open_hid_if_ready, open_with_retry, + parse_uac_named_card_candidates, parse_uac_numeric_card_ids, parse_uac_pcm_candidates, + preferred_uac_device_candidates, push_audio_candidate, push_audio_candidate_family, + should_recover_hid_error, write_hid_report, }; use serial_test::serial; use std::collections::BTreeSet; @@ -24,6 +25,40 @@ fn allow_gadget_cycle_tracks_env_presence() { }); } +#[test] +#[serial] +fn allow_gadget_cycle_defers_to_external_uvc_owner() { + with_var("LESAVKA_ALLOW_GADGET_CYCLE", Some("1"), || { + with_var("LESAVKA_UVC_EXTERNAL", Some("1"), || { + with_var("LESAVKA_DISABLE_UVC", None::<&str>, || { + with_var( + "LESAVKA_ALLOW_EXTERNAL_UVC_GADGET_CYCLE", + None::<&str>, + || { + assert!(external_uvc_helper_owns_gadget()); + assert!( + !allow_gadget_cycle(), + "server must not reset the gadget while external UVC owns it" + ); + }, + ); + }); + }); + }); +} + +#[test] +#[serial] +fn allow_gadget_cycle_can_be_forced_with_external_uvc_owner() { + with_var("LESAVKA_ALLOW_GADGET_CYCLE", Some("1"), || { + with_var("LESAVKA_UVC_EXTERNAL", Some("1"), || { + with_var("LESAVKA_ALLOW_EXTERNAL_UVC_GADGET_CYCLE", Some("1"), || { + assert!(allow_gadget_cycle()); + }); + }); + }); +} + #[test] fn should_recover_hid_error_matches_transport_failures() { assert!(should_recover_hid_error(Some(libc::ENOTCONN))); diff --git a/testing/tests/server_install_script_contract.rs b/testing/tests/server_install_script_contract.rs index e793c5e..8819671 100644 --- a/testing/tests/server_install_script_contract.rs +++ b/testing/tests/server_install_script_contract.rs @@ -131,6 +131,10 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { SERVER_INSTALL.contains("Wants=lesavka-uvc.service"), "server unit should pull in the external UVC helper on UVC installs" ); + assert!( + !SERVER_INSTALL.contains("Environment=LESAVKA_ALLOW_GADGET_CYCLE=1"), + "server unit should not auto-cycle the gadget while the external UVC helper owns it" + ); assert!( SERVER_INSTALL.contains("/var/log/lesavka/server.log"), "install script should keep server logs out of sticky /tmp" @@ -187,6 +191,14 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { SERVER_INSTALL.contains("validate_server_ready"), "install script should verify that lesavka-server reaches a running state" ); + assert!( + SERVER_INSTALL.contains("active and listening"), + "install script should require the TCP listener before declaring server readiness" + ); + assert!( + SERVER_INSTALL.contains("did not open TCP"), + "install script should explain active-but-not-listening server failures" + ); assert!( SERVER_INSTALL.contains("failed to reach active/running state"), "install script should explain server startup failures instead of claiming success"