//! 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) } 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) } #[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] #[cfg(coverage)] #[serial] fn reset_usb_reports_non_enumerated_and_unreadable_state_after_recovery() { let rt = tokio::runtime::Runtime::new().expect("runtime"); 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")); }); }, ); }); } #[test] #[cfg(coverage)] #[serial] fn reset_usb_reports_uvc_helper_restart_failure_after_recovery() { let rt = tokio::runtime::Runtime::new().expect("runtime"); 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")); }, ); }, ); }); } #[test] #[cfg(coverage)] #[serial] fn reset_usb_reports_reopen_hid_failure_after_successful_recovery() { let rt = tokio::runtime::Runtime::new().expect("runtime"); 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() ); }, ); }); } }