fix(usb): force gadget enumeration recovery
This commit is contained in:
parent
d60124ce94
commit
14613a319e
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.11.42"
|
||||
version = "0.11.43"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -621,7 +621,7 @@ impl LesavkaClientApp {
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("🔊🛟 USB gadget recovery failed: {err}");
|
||||
tracing::warn!("🔊🛟 USB gadget recovery failed: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.11.42"
|
||||
version = "0.11.43"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.11.42"
|
||||
version = "0.11.43"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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!(
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user