From 8b5dc220ad761eb18c74441ae7d29bfa22183784 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Mon, 27 Apr 2026 21:02:59 -0300 Subject: [PATCH] fix(sync): rebuild incomplete uvc gadgets --- Cargo.lock | 6 +-- client/Cargo.toml | 2 +- client/src/sync_probe/capture/tests.rs | 49 +++++++++++++++++++ common/Cargo.toml | 2 +- scripts/daemon/lesavka-core.sh | 23 +++++++-- scripts/install/server.sh | 10 +++- server/Cargo.toml | 2 +- .../tests/server_install_script_contract.rs | 12 +++++ 8 files changed, 95 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2a32311..02813eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.14.29" +version = "0.14.30" dependencies = [ "anyhow", "async-stream", @@ -1676,7 +1676,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.14.29" +version = "0.14.30" dependencies = [ "anyhow", "base64", @@ -1688,7 +1688,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.14.29" +version = "0.14.30" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index f9b1c37..a8c1506 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.14.29" +version = "0.14.30" edition = "2024" [dependencies] diff --git a/client/src/sync_probe/capture/tests.rs b/client/src/sync_probe/capture/tests.rs index 8566892..8f7226d 100644 --- a/client/src/sync_probe/capture/tests.rs +++ b/client/src/sync_probe/capture/tests.rs @@ -472,3 +472,52 @@ async fn runtime_probe_video_packets_change_across_a_pulse_boundary() { "expected decoded pulse frame to be much brighter than decoded dark frame, got dark={dark_mean} pulse={pulse_mean}" ); } + +#[cfg(not(coverage))] +#[tokio::test] +async fn runtime_probe_dark_video_packets_do_not_alternate_frame_to_frame() { + let capture = SyncProbeCapture::new( + CameraConfig { + codec: CameraCodec::Mjpeg, + width: 640, + height: 480, + fps: 20, + }, + PulseSchedule::new( + Duration::from_secs(4), + Duration::from_secs(1), + Duration::from_millis(120), + 5, + ), + Duration::from_secs(3), + ) + .expect("runtime capture"); + + let video_queue = capture.video_queue(); + let mut dark_means = Vec::new(); + + loop { + let next = video_queue.pop_fresh().await; + let Some(packet) = next.packet else { + break; + }; + if packet.pts >= 1_000_000 { + break; + } + dark_means.push(decode_mjpeg_packet_mean_luma(&packet)); + if dark_means.len() >= 8 { + break; + } + } + + assert!( + dark_means.len() >= 4, + "expected several dark packets before the first pulse, got {dark_means:?}" + ); + let min = *dark_means.iter().min().expect("dark min"); + let max = *dark_means.iter().max().expect("dark max"); + assert!( + max.saturating_sub(min) <= 2, + "expected consecutive dark MJPEG packets to stay visually stable, got {dark_means:?}" + ); +} diff --git a/common/Cargo.toml b/common/Cargo.toml index e68566e..ad3bcd7 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.14.29" +version = "0.14.30" edition = "2024" build = "build.rs" diff --git a/scripts/daemon/lesavka-core.sh b/scripts/daemon/lesavka-core.sh index ae4e8c7..37f1600 100755 --- a/scripts/daemon/lesavka-core.sh +++ b/scripts/daemon/lesavka-core.sh @@ -81,6 +81,18 @@ attach_gadget() { return 0 } +gadget_has_expected_functions() { + [[ -d $G/functions/hid.usb0 ]] || return 1 + [[ -d $G/functions/hid.usb1 ]] || return 1 + if [[ -z ${DISABLE_UAC:-} ]]; then + [[ -d $G/functions/uac2.usb0 ]] || return 1 + fi + if [[ -z ${DISABLE_UVC:-} ]]; then + [[ -d $G/functions/uvc.usb0 ]] || return 1 + fi + return 0 +} + case "${1:-}" in --detach) detach_gadget @@ -314,10 +326,13 @@ log "✅ UDC detected: $UDC" # If a gadget is already configured, avoid tearing it down unless forced. if [[ -d $G && -z $ALLOW_RESET ]]; then if [[ -s $G/UDC || -d $G/configs/c.1 ]]; then - log "🔒 gadget already configured; skipping reset." - log " Set LESAVKA_ALLOW_GADGET_RESET=1 to force rebuild." - attach_gadget || true - exit 0 + if gadget_has_expected_functions; then + log "🔒 gadget already configured; skipping reset." + log " Set LESAVKA_ALLOW_GADGET_RESET=1 to force rebuild." + attach_gadget || true + exit 0 + fi + log "⚠️ gadget is configured but missing expected functions; continuing toward rebuild." fi fi diff --git a/scripts/install/server.sh b/scripts/install/server.sh index b41e6bc..9708ece 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -90,6 +90,10 @@ validate_uvc_gadget_ready() { echo "✅ UVC gadget output ready at ${node}" } +uvc_gadget_present() { + [[ -d /sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0 ]] +} + udc_state() { local udc="" udc=$(ls /sys/class/udc 2>/dev/null | head -n1 || true) @@ -644,6 +648,10 @@ sudo systemctl enable lesavka-core lesavka-server UDC_STATE=$(udc_state) FORCE_GADGET_REBUILD=0 +if [[ -z ${LESAVKA_DISABLE_UVC:-} ]] && ! uvc_gadget_present; then + FORCE_GADGET_REBUILD=1 + echo "⚠️ UVC function is missing from the live gadget; forcing a rebuild before server start." +fi if [[ "$UVC_ENV_CHANGED" == "1" ]] && is_attached_state "$UDC_STATE"; then FORCE_GADGET_REBUILD=1 echo "⚠️ UVC runtime settings changed while the host is attached; forcing a gadget rebuild so the new descriptors take effect." @@ -676,7 +684,7 @@ ExecStart=/usr/local/bin/lesavka-uvc.sh Restart=always RestartSec=2 KillSignal=SIGTERM -KillMode=process +KillMode=control-group TimeoutStopSec=10 StandardError=append:/tmp/lesavka-uvc.stderr User=root diff --git a/server/Cargo.toml b/server/Cargo.toml index 6775d2f..59a5c38 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.14.29" +version = "0.14.30" edition = "2024" autobins = false diff --git a/testing/tests/server_install_script_contract.rs b/testing/tests/server_install_script_contract.rs index b0d1988..516fb75 100644 --- a/testing/tests/server_install_script_contract.rs +++ b/testing/tests/server_install_script_contract.rs @@ -70,6 +70,14 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { SERVER_INSTALL.contains("validate_uvc_gadget_ready"), "install script should verify that the UVC gadget comes back before declaring success" ); + assert!( + SERVER_INSTALL.contains("uvc_gadget_present"), + "install script should detect when the live gadget is missing the expected UVC function" + ); + assert!( + SERVER_INSTALL.contains("UVC function is missing from the live gadget; forcing a rebuild before server start."), + "install script should force a rebuild when the live gadget is attached but missing UVC" + ); assert!( SERVER_INSTALL.contains("video-output node did not appear after rebuild"), "install script should explain why it refuses a half-applied UVC install" @@ -78,6 +86,10 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { !SERVER_INSTALL.contains("RefuseManualStop=yes"), "install script should not generate a UVC unit that blocks legitimate refreshes" ); + assert!( + SERVER_INSTALL.contains("KillMode=control-group"), + "install script should stop the whole UVC helper cgroup instead of leaving child processes behind" + ); assert!( SERVER_INSTALL.contains("lesavka-uvc already active; runtime settings unchanged."), "install script should avoid unnecessary UVC restarts when nothing changed"