fix(usb): force gadget enumeration recovery

This commit is contained in:
Brad Stein 2026-04-21 19:09:52 -03:00
parent d60124ce94
commit 14613a319e
12 changed files with 523 additions and 72 deletions

View File

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

View File

@ -621,7 +621,7 @@ impl LesavkaClientApp {
}
}
Err(err) => {
tracing::warn!("🔊🛟 USB gadget recovery failed: {err}");
tracing::warn!("🔊🛟 USB gadget recovery failed: {err:#}");
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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::<u64>().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<String> {
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::<String>()
.chars()
.rev()
.collect();
format!("...{tail}")
}
}

View File

@ -79,8 +79,22 @@ impl Handler {
async fn new(gadget: UsbGadget) -> anyhow::Result<Self> {
#[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))
}
}
}
}

View File

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

View File

@ -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() {

View File

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

View File

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