fix(server): avoid auto gadget cycles with external uvc

This commit is contained in:
Brad Stein 2026-04-28 20:28:36 -03:00
parent ee7550dfe5
commit 5eb984ce08
9 changed files with 97 additions and 20 deletions

6
Cargo.lock generated
View File

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

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.14.42"
version = "0.14.43"
edition = "2024"
[dependencies]

View File

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

View File

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

View File

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

View File

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

View File

@ -155,15 +155,29 @@ pub fn hid_endpoint_open_is_temporarily_unavailable(code: Option<i32>) -> 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.

View File

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

View File

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