From 14613a319e09581766420cef81e7997321d2de1f Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Tue, 21 Apr 2026 19:09:52 -0300 Subject: [PATCH] fix(usb): force gadget enumeration recovery --- client/Cargo.toml | 2 +- client/src/app.rs | 2 +- client/src/launcher/ui.rs | 3 +- common/Cargo.toml | 2 +- scripts/install/server.sh | 9 + server/Cargo.toml | 2 +- server/src/gadget.rs | 157 +++++++++++++++- server/src/main.rs | 31 +++- server/src/runtime_support.rs | 10 +- .../tests/server_gadget_include_contract.rs | 91 ++++++++++ testing/tests/server_main_binary_contract.rs | 115 ++++++++++-- .../server_main_binary_extra_contract.rs | 171 ++++++++++++++---- 12 files changed, 523 insertions(+), 72 deletions(-) diff --git a/client/Cargo.toml b/client/Cargo.toml index 21d90e3..3b97c14 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.42" +version = "0.11.43" edition = "2024" [dependencies] diff --git a/client/src/app.rs b/client/src/app.rs index a7adbaa..5997e44 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -621,7 +621,7 @@ impl LesavkaClientApp { } } Err(err) => { - tracing::warn!("πŸ”ŠπŸ›Ÿ USB gadget recovery failed: {err}"); + tracing::warn!("πŸ”ŠπŸ›Ÿ USB gadget recovery failed: {err:#}"); } } } diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 8b5053d..8ed2e18 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -1445,7 +1445,8 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { ); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let result = reset_usb_gadget(&server_addr).map_err(|err| err.to_string()); + let result = + reset_usb_gadget(&server_addr).map_err(|err| format!("{err:#}")); let _ = tx.send(result); }); let widgets = widgets_for_click.clone(); diff --git a/common/Cargo.toml b/common/Cargo.toml index 4a48e95..132a0fa 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.42" +version = "0.11.43" edition = "2024" build = "build.rs" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index dd08632..c298c38 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -254,6 +254,15 @@ sudo systemctl enable lesavka-core lesavka-server UDC_STATE=$(udc_state) if [[ -n ${LESAVKA_ALLOW_GADGET_RESET:-} ]] || ! is_attached_state "$UDC_STATE"; then + echo "⚠️ UDC state is '$UDC_STATE' - forcing a Lesavka gadget rebuild before server start." + sudo env \ + LESAVKA_ALLOW_GADGET_RESET=1 \ + LESAVKA_ATTACH_WRITE_UDC=1 \ + LESAVKA_DETACH_CLEAR_UDC=1 \ + LESAVKA_RELOAD_UVCVIDEO=1 \ + LESAVKA_UVC_FALLBACK=1 \ + LESAVKA_UVC_CODEC=mjpeg \ + /usr/local/bin/lesavka-core.sh sudo systemctl restart lesavka-core echo "βœ… lesavka-core installed and restarted..." else diff --git a/server/Cargo.toml b/server/Cargo.toml index 0670a37..e9dbe60 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.42" +version = "0.11.43" edition = "2024" autobins = false diff --git a/server/src/gadget.rs b/server/src/gadget.rs index 423dfe7..d6da0c6 100644 --- a/server/src/gadget.rs +++ b/server/src/gadget.rs @@ -5,10 +5,13 @@ use std::{ fs::{self, OpenOptions}, io::Write, path::Path, + process::Command, thread, time::Duration, }; -use tracing::{info, trace, warn}; +#[cfg(not(coverage))] +use tracing::warn; +use tracing::{info, trace}; #[derive(Clone)] pub struct UsbGadget { @@ -51,6 +54,17 @@ impl UsbGadget { ) } + pub fn host_enumerated_state(state: &str) -> bool { + matches!(state, "configured" | "addressed" | "default" | "suspended") + } + + pub fn current_state_detail() -> String { + match Self::current_controller_state() { + Ok((ctrl, state)) => format!("UDC {ctrl} state={state}"), + Err(err) => format!("UDC state unavailable: {err:#}"), + } + } + /*---- helpers ----*/ /// Find the first controller in /sys/class/udc (e.g. `1000480000.usb`) @@ -143,6 +157,10 @@ impl UsbGadget { self.cycle_internal(true) } + pub fn recover_enumeration(&self) -> Result<()> { + self.recover_enumeration_internal() + } + #[cfg(coverage)] fn cycle_internal(&self, force_cycle: bool) -> Result<()> { let ctrl = Self::find_controller().or_else(|_| { @@ -355,4 +373,141 @@ impl UsbGadget { matches!(code, libc::EBUSY | libc::ENOENT | libc::ENODEV) }) } + + fn recover_enumeration_internal(&self) -> Result<()> { + let mut steps = Vec::new(); + steps.push(format!("initial {}", Self::current_state_detail())); + + let cycle_ok = match self.cycle_forced() { + Ok(()) => { + steps.push("forced UDC cycle succeeded".to_string()); + true + } + Err(err) => { + steps.push(format!("forced UDC cycle failed: {err:#}")); + false + } + }; + if cycle_ok { + if let Some((ctrl, state)) = + Self::wait_for_host_attach(Self::recovery_wait_ms("CYCLE", 2_000)) + { + info!("βœ… USB host enumerated after UDC cycle ctrl={ctrl} state={state}"); + return Ok(()); + } + } + + if !Self::rebuild_helper_available() { + anyhow::bail!( + "USB gadget recovery cannot continue because no UDC/controller is available for forced rebuild; {}", + steps.join("; ") + ); + } + + match self.run_forced_core_rebuild() { + Ok(summary) => steps.push(summary), + Err(err) => steps.push(format!("forced core rebuild failed: {err:#}")), + } + if let Some((ctrl, state)) = + Self::wait_for_host_attach(Self::recovery_wait_ms("REBUILD", 8_000)) + { + info!("βœ… USB host enumerated after forced gadget rebuild ctrl={ctrl} state={state}"); + return Ok(()); + } + + match self.cycle_forced() { + Ok(()) => steps.push("post-rebuild UDC cycle succeeded".to_string()), + Err(err) => steps.push(format!("post-rebuild UDC cycle failed: {err:#}")), + } + if let Some((ctrl, state)) = + Self::wait_for_host_attach(Self::recovery_wait_ms("FINAL", 4_000)) + { + info!("βœ… USB host enumerated after post-rebuild UDC cycle ctrl={ctrl} state={state}"); + return Ok(()); + } + + anyhow::bail!( + "USB gadget is still not attached after aggressive recovery; current {}; steps: {}", + Self::current_state_detail(), + steps.join("; ") + ) + } + + fn rebuild_helper_available() -> bool { + Self::find_controller().is_ok() + || matches!(Self::probe_platform_udc(), Ok(Some(_))) + || env::var("LESAVKA_FORCE_CORE_REBUILD_WITHOUT_UDC").is_ok() + } + + fn wait_for_host_attach(limit_ms: u64) -> Option<(String, String)> { + for _ in 0..=limit_ms / 100 { + if let Ok((ctrl, state)) = Self::current_controller_state() { + if Self::host_enumerated_state(&state) { + return Some((ctrl, state)); + } + } + thread::sleep(Duration::from_millis(100)); + } + None + } + + fn recovery_wait_ms(step: &str, default_ms: u64) -> u64 { + env::var(format!("LESAVKA_USB_RECOVERY_{step}_WAIT_MS")) + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(default_ms) + } + + fn core_helper_path() -> String { + env::var("LESAVKA_CORE_HELPER").unwrap_or_else(|_| "/usr/local/bin/lesavka-core.sh".into()) + } + + fn run_forced_core_rebuild(&self) -> Result { + let helper = Self::core_helper_path(); + let output = Command::new(&helper) + .env("LESAVKA_ALLOW_GADGET_RESET", "1") + .env("LESAVKA_ATTACH_WRITE_UDC", "1") + .env("LESAVKA_DETACH_CLEAR_UDC", "1") + .env("LESAVKA_RELOAD_UVCVIDEO", "1") + .env("LESAVKA_UVC_FALLBACK", "1") + .env( + "LESAVKA_UVC_CODEC", + env::var("LESAVKA_UVC_CODEC").unwrap_or_else(|_| "mjpeg".to_string()), + ) + .output() + .with_context(|| format!("running {helper} with forced gadget rebuild"))?; + + let stdout = Self::tail_text(&output.stdout); + let stderr = Self::tail_text(&output.stderr); + if !output.status.success() { + anyhow::bail!( + "forced gadget rebuild helper exited with {}; stderr: {}; stdout: {}", + output.status, + stderr, + stdout + ); + } + + Ok(format!( + "forced gadget rebuild helper succeeded: stderr: {}; stdout: {}", + stderr, stdout + )) + } + + fn tail_text(bytes: &[u8]) -> String { + let text = String::from_utf8_lossy(bytes).trim().to_string(); + const LIMIT: usize = 1_200; + if text.chars().count() <= LIMIT { + return text; + } + let tail: String = text + .chars() + .rev() + .take(LIMIT) + .collect::() + .chars() + .rev() + .collect(); + format!("...{tail}") + } } diff --git a/server/src/main.rs b/server/src/main.rs index dd21052..233bd6b 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -79,8 +79,22 @@ impl Handler { async fn new(gadget: UsbGadget) -> anyhow::Result { #[cfg(not(coverage))] if runtime_support::allow_gadget_cycle() { - info!("πŸ› οΈ Initial USB reset…"); - let _ = gadget.cycle(); // ignore failure - may boot without host + info!("πŸ› οΈ Initial USB recovery…"); + match UsbGadget::current_controller_state() { + Ok((ctrl, state)) if !UsbGadget::host_enumerated_state(&state) => { + warn!("⚠️ UDC {ctrl} is {state}; forcing gadget recovery before opening HID"); + if let Err(error) = gadget.recover_enumeration() { + warn!("⚠️ initial USB recovery did not enumerate the host: {error:#}"); + } + } + Ok(_) => { + let _ = gadget.cycle(); // ignore failure - may boot without host + } + Err(error) => { + warn!("⚠️ UDC state unavailable during startup: {error:#}"); + let _ = gadget.cycle(); // preserve the old best-effort startup path + } + } } #[cfg(not(coverage))] { @@ -333,7 +347,7 @@ impl Handler { #[cfg(not(coverage))] info!("πŸ”΄ explicit ResetUsb() called"); - match self.gadget.cycle_forced() { + match self.gadget.recover_enumeration() { Ok(_) => { if let Err(e) = self.reopen_hid().await { #[cfg(not(coverage))] @@ -346,7 +360,7 @@ impl Handler { return Err(Status::internal(e.to_string())); } match UsbGadget::current_controller_state() { - Ok((ctrl, state)) if UsbGadget::host_attached_state(&state) => { + Ok((ctrl, state)) if UsbGadget::host_enumerated_state(&state) => { #[cfg(not(coverage))] info!( "βœ… USB host enumerated gadget after recovery ctrl={ctrl} state={state}" @@ -373,8 +387,13 @@ impl Handler { } Err(e) => { #[cfg(not(coverage))] - error!("πŸ’₯ cycle failed: {e:#}"); - Err(Status::internal(e.to_string())) + error!("πŸ’₯ USB recovery failed: {e:#}"); + let message = format!("{e:#}"); + if message.contains("still not attached") || message.contains("not attached") { + Err(Status::failed_precondition(message)) + } else { + Err(Status::internal(message)) + } } } } diff --git a/server/src/runtime_support.rs b/server/src/runtime_support.rs index a8555dc..afa93ac 100644 --- a/server/src/runtime_support.rs +++ b/server/src/runtime_support.rs @@ -214,11 +214,11 @@ pub async fn recover_hid_if_needed( let allow_cycle = allow_gadget_cycle(); tokio::spawn(async move { if allow_cycle { - warn!("πŸ” HID transport down (errno={code:?}) - cycling gadget"); - match tokio::task::spawn_blocking(move || gadget.cycle()).await { - Ok(Ok(())) => info!("βœ… USB gadget cycle complete (auto-recover)"), - Ok(Err(error)) => error!("πŸ’₯ USB gadget cycle failed: {error:#}"), - Err(error) => error!("πŸ’₯ USB gadget cycle task panicked: {error:#}"), + warn!("πŸ” HID transport down (errno={code:?}) - aggressively recovering gadget"); + match tokio::task::spawn_blocking(move || gadget.recover_enumeration()).await { + Ok(Ok(())) => info!("βœ… USB gadget recovery complete (auto-recover)"), + Ok(Err(error)) => error!("πŸ’₯ USB gadget recovery failed: {error:#}"), + Err(error) => error!("πŸ’₯ USB gadget recovery task panicked: {error:#}"), } } else { warn!( diff --git a/testing/tests/server_gadget_include_contract.rs b/testing/tests/server_gadget_include_contract.rs index dc75bdd..b94c3ca 100644 --- a/testing/tests/server_gadget_include_contract.rs +++ b/testing/tests/server_gadget_include_contract.rs @@ -11,6 +11,7 @@ mod gadget_include_contract { include!(env!("LESAVKA_SERVER_GADGET_SRC")); use serial_test::serial; + use std::os::unix::fs::PermissionsExt; use temp_env::with_var; use tempfile::{NamedTempFile, tempdir}; @@ -52,6 +53,26 @@ mod gadget_include_contract { ); } + fn write_helper(path: &Path, body: &str) { + write_file(path, body); + let mut perms = std::fs::metadata(path) + .expect("helper metadata") + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(path, perms).expect("chmod helper"); + } + + fn with_fast_recovery_env(helper: &Path, f: impl FnOnce()) { + let helper = helper.to_string_lossy().to_string(); + with_var("LESAVKA_CORE_HELPER", Some(helper), || { + with_var("LESAVKA_USB_RECOVERY_CYCLE_WAIT_MS", Some("0"), || { + with_var("LESAVKA_USB_RECOVERY_REBUILD_WAIT_MS", Some("0"), || { + with_var("LESAVKA_USB_RECOVERY_FINAL_WAIT_MS", Some("0"), f); + }) + }) + }); + } + #[test] fn new_builds_expected_udc_path() { let gadget = UsbGadget::new("lesavka-test"); @@ -73,6 +94,15 @@ mod gadget_include_contract { assert!(!UsbGadget::host_attached_state("broken")); } + #[test] + fn host_enumerated_state_excludes_unknown_and_not_attached() { + for state in ["configured", "addressed", "default", "suspended"] { + assert!(UsbGadget::host_enumerated_state(state), "{state}"); + } + assert!(!UsbGadget::host_enumerated_state("unknown")); + assert!(!UsbGadget::host_enumerated_state("not attached")); + } + #[test] #[serial] fn current_controller_state_reads_fake_udc_tree() { @@ -244,6 +274,67 @@ mod gadget_include_contract { writer.join().expect("join state writer"); } + #[test] + #[serial] + fn recover_enumeration_runs_forced_core_rebuild_after_stuck_soft_cycle() { + let dir = tempdir().expect("tempdir"); + let ctrl = "fake-ctrl.usb"; + build_fake_tree(dir.path(), ctrl, "lesavka-test", "not attached"); + let helper = dir.path().join("fake-core.sh"); + write_helper( + &helper, + r#"#!/usr/bin/env bash +set -euo pipefail +echo forced core helper >&2 +printf 'configured\n' > "$LESAVKA_GADGET_SYSFS_ROOT/class/udc/fake-ctrl.usb/state" +"#, + ); + + with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { + with_fast_recovery_env(&helper, || { + let gadget = UsbGadget::new("lesavka-test"); + gadget + .recover_enumeration() + .expect("forced rebuild should recover fake UDC"); + }); + }); + + let state = std::fs::read_to_string(dir.path().join(format!("sys/class/udc/{ctrl}/state"))) + .expect("read state"); + assert_eq!(state.trim(), "configured"); + } + + #[test] + #[serial] + fn recover_enumeration_reports_clear_failure_when_helper_leaves_udc_unattached() { + let dir = tempdir().expect("tempdir"); + let ctrl = "fake-ctrl.usb"; + build_fake_tree(dir.path(), ctrl, "lesavka-test", "not attached"); + let helper = dir.path().join("fake-core-noop.sh"); + write_helper( + &helper, + r#"#!/usr/bin/env bash +set -euo pipefail +echo noop core helper >&2 +"#, + ); + + with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { + with_fast_recovery_env(&helper, || { + let gadget = UsbGadget::new("lesavka-test"); + let err = gadget + .recover_enumeration() + .expect_err("still-unattached UDC should fail recovery"); + let message = format!("{err:#}"); + assert!(message.contains("still not attached"), "{message}"); + assert!( + message.contains("forced gadget rebuild helper"), + "{message}" + ); + }); + }); + } + #[test] #[serial] fn probe_platform_udc_reads_fake_platform_tree() { diff --git a/testing/tests/server_main_binary_contract.rs b/testing/tests/server_main_binary_contract.rs index 6fc1262..7e1dc5d 100644 --- a/testing/tests/server_main_binary_contract.rs +++ b/testing/tests/server_main_binary_contract.rs @@ -76,21 +76,55 @@ mod server_main_binary { #[test] #[serial] - fn main_returns_error_without_hid_nodes() { + fn handler_new_tolerates_missing_hid_nodes_without_cycle() { + let dir = tempdir().expect("tempdir"); with_var("LESAVKA_DISABLE_UVC", Some("1"), || { with_var("LESAVKA_ALLOW_GADGET_CYCLE", None::<&str>, || { - let _ = std::panic::catch_unwind(main); + with_var( + "LESAVKA_HID_DIR", + Some(dir.path().join("missing").to_string_lossy().to_string()), + || { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let handler = rt + .block_on(Handler::new(UsbGadget::new("lesavka"))) + .expect("server should stay up while HID endpoints appear"); + let endpoints = rt.block_on(async { + ( + handler.kb.lock().await.is_none(), + handler.ms.lock().await.is_none(), + ) + }); + assert_eq!(endpoints, (true, true)); + }, + ); }); }); } #[test] #[serial] - fn main_covers_external_uvc_helper_branch_before_failing_without_hid_nodes() { + fn handler_new_tolerates_missing_hid_nodes_with_external_uvc() { + let dir = tempdir().expect("tempdir"); with_var("LESAVKA_DISABLE_UVC", None::<&str>, || { with_var("LESAVKA_UVC_EXTERNAL", Some("1"), || { with_var("LESAVKA_ALLOW_GADGET_CYCLE", None::<&str>, || { - let _ = std::panic::catch_unwind(main); + with_var( + "LESAVKA_HID_DIR", + Some(dir.path().join("missing").to_string_lossy().to_string()), + || { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let handler = rt + .block_on(Handler::new(UsbGadget::new("lesavka"))) + .expect("external UVC mode should still tolerate missing HID"); + let endpoints = rt.block_on(async { + ( + handler.kb.lock().await.is_none(), + handler.ms.lock().await.is_none(), + ) + }); + assert_eq!(endpoints, (true, true)); + }, + ); }); }); }); @@ -98,7 +132,8 @@ mod server_main_binary { #[test] #[serial] - fn main_spawns_uvc_supervisor_branch_before_failing_without_hid_nodes() { + fn handler_new_tolerates_missing_hid_nodes_when_cycle_is_enabled() { + let dir = tempdir().expect("tempdir"); with_var("LESAVKA_DISABLE_UVC", None::<&str>, || { with_var("LESAVKA_UVC_EXTERNAL", None::<&str>, || { with_var( @@ -106,7 +141,25 @@ mod server_main_binary { Some("/definitely/missing/uvc-helper"), || { with_var("LESAVKA_ALLOW_GADGET_CYCLE", Some("1"), || { - let _ = std::panic::catch_unwind(main); + with_var( + "LESAVKA_HID_DIR", + Some(dir.path().join("missing").to_string_lossy().to_string()), + || { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let handler = rt + .block_on(Handler::new(UsbGadget::new("lesavka"))) + .expect( + "cycle-enabled startup should tolerate missing HID", + ); + let endpoints = rt.block_on(async { + ( + handler.kb.lock().await.is_none(), + handler.ms.lock().await.is_none(), + ) + }); + assert_eq!(endpoints, (true, true)); + }, + ); }); }, ); @@ -116,28 +169,50 @@ mod server_main_binary { #[test] #[serial] - fn handler_new_fails_fast_without_hid_endpoints() { + fn handler_new_opens_missing_hid_endpoints_as_lazy_none() { + let dir = tempdir().expect("tempdir"); with_var("LESAVKA_ALLOW_GADGET_CYCLE", None::<&str>, || { - let rt = tokio::runtime::Runtime::new().expect("runtime"); - let result = rt.block_on(Handler::new(UsbGadget::new("lesavka"))); - let err = match result { - Ok(_) => panic!("missing hid nodes should fail startup"), - Err(err) => err, - }; - let msg = err.to_string(); - assert!(msg.contains("/dev/hidg0") || msg.contains("No such file")); + with_var( + "LESAVKA_HID_DIR", + Some(dir.path().join("missing").to_string_lossy().to_string()), + || { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let handler = rt + .block_on(Handler::new(UsbGadget::new("lesavka"))) + .expect("missing hid nodes should be lazy-opened later"); + let endpoints = rt.block_on(async { + ( + handler.kb.lock().await.is_none(), + handler.ms.lock().await.is_none(), + ) + }); + assert_eq!(endpoints, (true, true)); + }, + ); }); } #[test] #[serial] fn handler_new_attempts_cycle_when_explicitly_enabled() { + let dir = tempdir().expect("tempdir"); with_var("LESAVKA_ALLOW_GADGET_CYCLE", Some("1"), || { - let rt = tokio::runtime::Runtime::new().expect("runtime"); - let result = rt.block_on(Handler::new(UsbGadget::new("lesavka"))); - assert!( - result.is_err(), - "startup should still fail without hid endpoints even after cycle attempt" + with_var( + "LESAVKA_HID_DIR", + Some(dir.path().join("missing").to_string_lossy().to_string()), + || { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let handler = rt + .block_on(Handler::new(UsbGadget::new("lesavka"))) + .expect("cycle-enabled startup should still tolerate lazy HID"); + let endpoints = rt.block_on(async { + ( + handler.kb.lock().await.is_none(), + handler.ms.lock().await.is_none(), + ) + }); + assert_eq!(endpoints, (true, true)); + }, ); }); } diff --git a/testing/tests/server_main_binary_extra_contract.rs b/testing/tests/server_main_binary_extra_contract.rs index a3069a9..c4c15d7 100644 --- a/testing/tests/server_main_binary_extra_contract.rs +++ b/testing/tests/server_main_binary_extra_contract.rs @@ -13,6 +13,7 @@ mod server_main_binary_extra { use futures_util::stream; use lesavka_common::lesavka::relay_client::RelayClient; use serial_test::serial; + use std::os::unix::fs::PermissionsExt; use std::path::Path; use temp_env::with_var; use tempfile::tempdir; @@ -62,6 +63,26 @@ mod server_main_binary_extra { write_file(&base.join("sys/bus/platform/drivers/dwc3/bind"), ""); } + fn write_helper(path: &Path, body: &str) { + write_file(path, body); + let mut perms = std::fs::metadata(path) + .expect("helper metadata") + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(path, perms).expect("chmod helper"); + } + + fn with_fast_usb_recovery(helper: &Path, f: impl FnOnce()) { + let helper = helper.to_string_lossy().to_string(); + with_var("LESAVKA_CORE_HELPER", Some(helper), || { + with_var("LESAVKA_USB_RECOVERY_CYCLE_WAIT_MS", Some("0"), || { + with_var("LESAVKA_USB_RECOVERY_REBUILD_WAIT_MS", Some("0"), || { + with_var("LESAVKA_USB_RECOVERY_FINAL_WAIT_MS", Some("0"), f); + }) + }) + }); + } + fn build_handler_for_tests_with_modes( kb_writable: bool, ms_writable: bool, @@ -448,47 +469,127 @@ mod server_main_binary_extra { std::fs::write(hid_dir.join("hidg0"), "").expect("create hidg0"); std::fs::write(hid_dir.join("hidg1"), "").expect("create hidg1"); build_fake_gadget_tree(dir.path(), "fake-ctrl.usb", "lesavka", "not attached"); + let helper = dir.path().join("noop-core.sh"); + write_helper( + &helper, + r#"#!/usr/bin/env bash +set -euo pipefail +echo noop core helper >&2 +"#, + ); with_fake_gadget_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { with_var( "LESAVKA_HID_DIR", Some(hid_dir.to_string_lossy().to_string()), || { - let kb = tokio::fs::File::from_std( - std::fs::OpenOptions::new() - .read(true) - .write(true) - .open(hid_dir.join("hidg0")) - .expect("open hidg0"), - ); - let ms = tokio::fs::File::from_std( - std::fs::OpenOptions::new() - .read(true) - .write(true) - .open(hid_dir.join("hidg1")) - .expect("open hidg1"), - ); - let handler = Handler { - kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))), - ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))), - gadget: UsbGadget::new("lesavka"), - did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - camera_rt: std::sync::Arc::new(CameraRuntime::new()), - capture_power: CapturePowerManager::new(), - eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new( - std::collections::HashMap::new(), - )), - }; - let rt = tokio::runtime::Runtime::new().expect("runtime"); - let err = rt - .block_on(async { handler.reset_usb(tonic::Request::new(Empty {})).await }) - .expect_err("reset usb should report a host that never enumerates"); - assert_eq!(err.code(), tonic::Code::FailedPrecondition); - assert!( - err.message().contains("still not attached"), - "unexpected reset error: {}", - err.message() - ); + with_fast_usb_recovery(&helper, || { + let kb = tokio::fs::File::from_std( + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(hid_dir.join("hidg0")) + .expect("open hidg0"), + ); + let ms = tokio::fs::File::from_std( + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(hid_dir.join("hidg1")) + .expect("open hidg1"), + ); + let handler = Handler { + kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))), + ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))), + gadget: UsbGadget::new("lesavka"), + did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new( + false, + )), + camera_rt: std::sync::Arc::new(CameraRuntime::new()), + capture_power: CapturePowerManager::new(), + eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new( + std::collections::HashMap::new(), + )), + }; + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let err = rt + .block_on(async { + handler.reset_usb(tonic::Request::new(Empty {})).await + }) + .expect_err("reset usb should report a host that never enumerates"); + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!( + err.message().contains("still not attached"), + "unexpected reset error: {}", + err.message() + ); + }); + }, + ); + }); + } + + #[test] + #[serial] + fn reset_usb_forced_rebuild_can_recover_unattached_fake_udc() { + let dir = tempdir().expect("tempdir"); + let hid_dir = dir.path().join("hid"); + std::fs::create_dir_all(&hid_dir).expect("create hid dir"); + std::fs::write(hid_dir.join("hidg0"), "").expect("create hidg0"); + std::fs::write(hid_dir.join("hidg1"), "").expect("create hidg1"); + build_fake_gadget_tree(dir.path(), "fake-ctrl.usb", "lesavka", "not attached"); + let helper = dir.path().join("recover-core.sh"); + write_helper( + &helper, + r#"#!/usr/bin/env bash +set -euo pipefail +echo recover core helper >&2 +printf 'configured\n' > "$LESAVKA_GADGET_SYSFS_ROOT/class/udc/fake-ctrl.usb/state" +"#, + ); + + with_fake_gadget_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { + with_var( + "LESAVKA_HID_DIR", + Some(hid_dir.to_string_lossy().to_string()), + || { + with_fast_usb_recovery(&helper, || { + let kb = tokio::fs::File::from_std( + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(hid_dir.join("hidg0")) + .expect("open hidg0"), + ); + let ms = tokio::fs::File::from_std( + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(hid_dir.join("hidg1")) + .expect("open hidg1"), + ); + let handler = Handler { + kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))), + ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))), + gadget: UsbGadget::new("lesavka"), + did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new( + false, + )), + camera_rt: std::sync::Arc::new(CameraRuntime::new()), + capture_power: CapturePowerManager::new(), + eye_hubs: std::sync::Arc::new(tokio::sync::Mutex::new( + std::collections::HashMap::new(), + )), + }; + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let reply = rt + .block_on(async { + handler.reset_usb(tonic::Request::new(Empty {})).await + }) + .expect("reset usb should recover fake host") + .into_inner(); + assert!(reply.ok); + }); }, ); });