2026-04-21 21:38:22 -03:00
|
|
|
//! USB reset and eye-hub coverage for server main relay branches.
|
|
|
|
|
//!
|
|
|
|
|
//! Scope: include `server/src/main.rs` and exercise USB recovery plus shared
|
|
|
|
|
//! eye-feed hub behavior with synthetic endpoints.
|
|
|
|
|
//! Targets: `server/src/main.rs`.
|
|
|
|
|
//! Why: USB recovery and shared downstream video hubs are operational escape
|
|
|
|
|
//! hatches; regressions here can leave HID or eye feeds unavailable.
|
|
|
|
|
|
|
|
|
|
#[allow(warnings)]
|
|
|
|
|
mod server_main_binary_extra {
|
|
|
|
|
include!(env!("LESAVKA_SERVER_MAIN_SRC"));
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
async fn connect_with_retry(addr: std::net::SocketAddr) -> tonic::transport::Channel {
|
|
|
|
|
let endpoint = tonic::transport::Endpoint::from_shared(format!("http://{addr}"))
|
|
|
|
|
.expect("endpoint")
|
|
|
|
|
.tcp_nodelay(true);
|
|
|
|
|
for _ in 0..40 {
|
|
|
|
|
if let Ok(channel) = endpoint.clone().connect().await {
|
|
|
|
|
return channel;
|
|
|
|
|
}
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
|
|
|
|
}
|
|
|
|
|
panic!("failed to connect to local tonic server");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write_file(path: &Path, content: &str) {
|
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
std::fs::create_dir_all(parent).expect("create parent");
|
|
|
|
|
}
|
|
|
|
|
std::fs::write(path, content).expect("write file");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn with_fake_gadget_roots(sys_root: &Path, cfg_root: &Path, f: impl FnOnce()) {
|
|
|
|
|
let sys_root = sys_root.to_string_lossy().to_string();
|
|
|
|
|
let cfg_root = cfg_root.to_string_lossy().to_string();
|
|
|
|
|
with_var("LESAVKA_GADGET_SYSFS_ROOT", Some(sys_root), || {
|
|
|
|
|
with_var("LESAVKA_GADGET_CONFIGFS_ROOT", Some(cfg_root), f);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn with_capture_power_disabled(f: impl FnOnce()) {
|
|
|
|
|
with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), f);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_fake_gadget_tree(base: &Path, ctrl: &str, gadget_name: &str, state: &str) {
|
|
|
|
|
write_file(
|
|
|
|
|
&base.join(format!("sys/class/udc/{ctrl}/state")),
|
|
|
|
|
&format!("{state}\n"),
|
|
|
|
|
);
|
|
|
|
|
write_file(
|
|
|
|
|
&base.join(format!("cfg/{gadget_name}/UDC")),
|
|
|
|
|
&format!("{ctrl}\n"),
|
|
|
|
|
);
|
|
|
|
|
write_file(&base.join("sys/bus/platform/drivers/dwc3/unbind"), "");
|
|
|
|
|
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,
|
|
|
|
|
) -> (tempfile::TempDir, Handler) {
|
|
|
|
|
let dir = tempdir().expect("tempdir");
|
|
|
|
|
let kb_path = dir.path().join("hidg0.bin");
|
|
|
|
|
let ms_path = dir.path().join("hidg1.bin");
|
|
|
|
|
std::fs::write(&kb_path, []).expect("create kb file");
|
|
|
|
|
std::fs::write(&ms_path, []).expect("create ms file");
|
|
|
|
|
|
|
|
|
|
let kb_std = std::fs::OpenOptions::new()
|
|
|
|
|
.read(true)
|
|
|
|
|
.write(kb_writable)
|
|
|
|
|
.create(kb_writable)
|
|
|
|
|
.truncate(kb_writable)
|
|
|
|
|
.open(&kb_path)
|
|
|
|
|
.expect("open kb");
|
|
|
|
|
let ms_std = std::fs::OpenOptions::new()
|
|
|
|
|
.read(true)
|
|
|
|
|
.write(ms_writable)
|
|
|
|
|
.create(ms_writable)
|
|
|
|
|
.truncate(ms_writable)
|
|
|
|
|
.open(&ms_path)
|
|
|
|
|
.expect("open ms");
|
|
|
|
|
let kb = tokio::fs::File::from_std(kb_std);
|
|
|
|
|
let ms = tokio::fs::File::from_std(ms_std);
|
|
|
|
|
|
|
|
|
|
(
|
|
|
|
|
dir,
|
|
|
|
|
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(),
|
|
|
|
|
)),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_handler_for_tests() -> (tempfile::TempDir, Handler) {
|
|
|
|
|
build_handler_for_tests_with_modes(true, true)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 03:49:49 -03:00
|
|
|
fn handler_for_hid_dir(hid_dir: &Path) -> Handler {
|
|
|
|
|
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"),
|
|
|
|
|
);
|
|
|
|
|
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()),
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn prepare_reset_fixture(state: &str) -> (tempfile::TempDir, std::path::PathBuf) {
|
|
|
|
|
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", state);
|
|
|
|
|
(dir, hid_dir)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 21:38:22 -03:00
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn reset_usb_reports_host_not_attached_after_fake_cycle() {
|
|
|
|
|
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("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()),
|
|
|
|
|
|| {
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-04-23 03:49:49 -03:00
|
|
|
#[cfg(coverage)]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn reset_usb_reports_non_enumerated_and_unreadable_state_after_recovery() {
|
2026-04-21 21:38:22 -03:00
|
|
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
2026-04-23 03:49:49 -03:00
|
|
|
let (dir, hid_dir) = prepare_reset_fixture("configured");
|
|
|
|
|
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 handler = handler_for_hid_dir(&hid_dir);
|
|
|
|
|
with_var("LESAVKA_TEST_RECOVERY_STATE", Some("not attached"), || {
|
|
|
|
|
let err = rt
|
|
|
|
|
.block_on(async {
|
|
|
|
|
handler.reset_usb(tonic::Request::new(Empty {})).await
|
|
|
|
|
})
|
|
|
|
|
.expect_err("non-enumerated recovery state");
|
|
|
|
|
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
|
|
|
|
|
assert!(err.message().contains("still not attached"));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
with_var("LESAVKA_TEST_RECOVERY_STATE_ERROR", Some("1"), || {
|
|
|
|
|
let err = rt
|
|
|
|
|
.block_on(async {
|
|
|
|
|
handler.reset_usb(tonic::Request::new(Empty {})).await
|
|
|
|
|
})
|
|
|
|
|
.expect_err("unreadable recovery state");
|
|
|
|
|
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
|
|
|
|
|
assert!(err.message().contains("cannot read UDC state"));
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-04-21 21:38:22 -03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-04-23 03:49:49 -03:00
|
|
|
#[cfg(coverage)]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn reset_usb_reports_uvc_helper_restart_failure_after_recovery() {
|
2026-04-21 21:38:22 -03:00
|
|
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
2026-04-23 03:49:49 -03:00
|
|
|
let (dir, hid_dir) = prepare_reset_fixture("configured");
|
|
|
|
|
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_var(
|
|
|
|
|
"LESAVKA_TEST_UVC_HELPER_RESTART_ERR",
|
|
|
|
|
Some("synthetic restart failure"),
|
|
|
|
|
|| {
|
|
|
|
|
let handler = handler_for_hid_dir(&hid_dir);
|
|
|
|
|
let err = rt
|
|
|
|
|
.block_on(async {
|
|
|
|
|
handler.reset_usb(tonic::Request::new(Empty {})).await
|
|
|
|
|
})
|
|
|
|
|
.expect_err("restart helper failure");
|
|
|
|
|
assert_eq!(err.code(), tonic::Code::Internal);
|
|
|
|
|
assert!(err.message().contains("synthetic restart failure"));
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-04-21 21:38:22 -03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-04-23 03:49:49 -03:00
|
|
|
#[cfg(coverage)]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn reset_usb_reports_reopen_hid_failure_after_successful_recovery() {
|
2026-04-21 21:38:22 -03:00
|
|
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
2026-04-23 03:49:49 -03:00
|
|
|
let (dir, hid_dir) = prepare_reset_fixture("configured");
|
|
|
|
|
let bad_hid_dir = dir.path().join("bad-hid");
|
|
|
|
|
std::fs::create_dir_all(bad_hid_dir.join("hidg0")).expect("create bad hidg0 dir");
|
|
|
|
|
std::fs::create_dir_all(bad_hid_dir.join("hidg1")).expect("create bad hidg1 dir");
|
|
|
|
|
|
|
|
|
|
with_fake_gadget_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || {
|
|
|
|
|
with_var(
|
|
|
|
|
"LESAVKA_HID_DIR",
|
|
|
|
|
Some(bad_hid_dir.to_string_lossy().to_string()),
|
|
|
|
|
|| {
|
|
|
|
|
let handler = handler_for_hid_dir(&hid_dir);
|
|
|
|
|
let err = rt
|
|
|
|
|
.block_on(async { handler.reset_usb(tonic::Request::new(Empty {})).await })
|
|
|
|
|
.expect_err("bad HID paths should fail after USB recovery");
|
|
|
|
|
assert_eq!(err.code(), tonic::Code::Internal);
|
|
|
|
|
assert!(
|
|
|
|
|
err.message().contains("opening"),
|
|
|
|
|
"unexpected reset error: {}",
|
|
|
|
|
err.message()
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-04-21 21:38:22 -03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|