fix(server): survive unattached HID gadget startup

This commit is contained in:
Brad Stein 2026-04-21 13:08:20 -03:00
parent b8e43cac6f
commit c65fcd1137
12 changed files with 174 additions and 83 deletions

View File

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

View File

@ -182,7 +182,8 @@ pub fn build_launcher_view(
let operations = gtk::Box::new(gtk::Orientation::Vertical, 8); let operations = gtk::Box::new(gtk::Orientation::Vertical, 8);
operations.set_size_request(OPERATIONS_RAIL_WIDTH, -1); operations.set_size_request(OPERATIONS_RAIL_WIDTH, -1);
operations.set_hexpand(false); operations.set_hexpand(false);
operations.set_vexpand(true); operations.set_vexpand(false);
operations.set_valign(gtk::Align::Start);
content.append(&operations); content.append(&operations);
let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); let display_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
@ -320,14 +321,14 @@ pub fn build_launcher_view(
let (preview_panel, preview_body) = build_panel("Device Testing"); let (preview_panel, preview_body) = build_panel("Device Testing");
preview_panel.set_hexpand(true); preview_panel.set_hexpand(true);
preview_panel.set_vexpand(false); preview_panel.set_vexpand(true);
preview_panel.set_valign(gtk::Align::Fill); preview_panel.set_valign(gtk::Align::Fill);
preview_body.set_vexpand(false); preview_body.set_vexpand(true);
preview_body.set_spacing(8); preview_body.set_spacing(8);
let testing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); let testing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
testing_row.set_hexpand(true); testing_row.set_hexpand(true);
testing_row.set_vexpand(false); testing_row.set_vexpand(true);
testing_row.set_valign(gtk::Align::Start); testing_row.set_valign(gtk::Align::Fill);
let camera_preview = gtk::Picture::new(); let camera_preview = gtk::Picture::new();
camera_preview.set_can_shrink(false); camera_preview.set_can_shrink(false);
camera_preview.set_hexpand(false); camera_preview.set_hexpand(false);
@ -363,15 +364,15 @@ pub fn build_launcher_view(
camera_preview_shell.append(&camera_preview_frame); camera_preview_shell.append(&camera_preview_frame);
let webcam_group = build_subgroup("Webcam Preview"); let webcam_group = build_subgroup("Webcam Preview");
webcam_group.set_hexpand(true); webcam_group.set_hexpand(true);
webcam_group.set_vexpand(false); webcam_group.set_vexpand(true);
webcam_group.set_valign(gtk::Align::Start); webcam_group.set_valign(gtk::Align::Fill);
webcam_group.append(&camera_preview_shell); webcam_group.append(&camera_preview_shell);
testing_row.append(&webcam_group); testing_row.append(&webcam_group);
let playback_group = build_subgroup("Mic Playback"); let playback_group = build_subgroup("Mic Playback");
playback_group.set_hexpand(false); playback_group.set_hexpand(false);
playback_group.set_vexpand(false); playback_group.set_vexpand(true);
playback_group.set_valign(gtk::Align::Start); playback_group.set_valign(gtk::Align::Fill);
playback_group.set_size_request(72, -1); playback_group.set_size_request(72, -1);
let playback_body = gtk::Box::new(gtk::Orientation::Vertical, 6); let playback_body = gtk::Box::new(gtk::Orientation::Vertical, 6);
playback_body.set_halign(gtk::Align::Center); playback_body.set_halign(gtk::Align::Center);
@ -512,7 +513,8 @@ pub fn build_launcher_view(
let diagnostics_scroll = gtk::ScrolledWindow::builder() let diagnostics_scroll = gtk::ScrolledWindow::builder()
.hexpand(true) .hexpand(true)
.vexpand(false) .vexpand(false)
.min_content_height(190) .min_content_height(150)
.max_content_height(150)
.child(&diagnostics_shell) .child(&diagnostics_shell)
.build(); .build();
diagnostics_body.append(&diagnostics_toolbar); diagnostics_body.append(&diagnostics_toolbar);
@ -520,7 +522,7 @@ pub fn build_launcher_view(
operations.append(&diagnostics_panel); operations.append(&diagnostics_panel);
let (console_panel, console_body) = build_panel("Session Console"); let (console_panel, console_body) = build_panel("Session Console");
console_panel.set_vexpand(true); console_panel.set_vexpand(false);
let console_toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8); let console_toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 8);
console_toolbar.set_homogeneous(true); console_toolbar.set_homogeneous(true);
let console_copy_button = gtk::Button::with_label("Copy Log"); let console_copy_button = gtk::Button::with_label("Copy Log");
@ -550,8 +552,9 @@ pub fn build_launcher_view(
session_log_view.set_wrap_mode(gtk::WrapMode::WordChar); session_log_view.set_wrap_mode(gtk::WrapMode::WordChar);
let log_scroll = gtk::ScrolledWindow::builder() let log_scroll = gtk::ScrolledWindow::builder()
.hexpand(true) .hexpand(true)
.vexpand(true) .vexpand(false)
.min_content_height(220) .min_content_height(150)
.max_content_height(150)
.child(&session_log_view) .child(&session_log_view)
.build(); .build();
console_body.append(&console_toolbar); console_body.append(&console_toolbar);

View File

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

View File

@ -17,6 +17,6 @@ mod tests {
#[test] #[test]
fn banner_includes_version() { fn banner_includes_version() {
assert_eq!(banner("0.11.34"), "lesavka-common CLI (v0.11.34)"); assert_eq!(banner("0.11.35"), "lesavka-common CLI (v0.11.35)");
} }
} }

View File

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

View File

@ -51,8 +51,8 @@ fn live_keyboard_report_delay() -> Duration {
/*──────────────── Handler ───────────────────*/ /*──────────────── Handler ───────────────────*/
struct Handler { struct Handler {
kb: Arc<Mutex<tokio::fs::File>>, kb: Arc<Mutex<Option<tokio::fs::File>>>,
ms: Arc<Mutex<tokio::fs::File>>, ms: Arc<Mutex<Option<tokio::fs::File>>>,
gadget: UsbGadget, gadget: UsbGadget,
did_cycle: Arc<AtomicBool>, did_cycle: Arc<AtomicBool>,
camera_rt: Arc<CameraRuntime>, camera_rt: Arc<CameraRuntime>,
@ -91,10 +91,16 @@ impl Handler {
} }
info!("🛠️ opening HID endpoints …"); info!("🛠️ opening HID endpoints …");
} }
let kb = runtime_support::open_with_retry(&hid_endpoint(0)).await?; let kb_path = hid_endpoint(0);
let ms = runtime_support::open_with_retry(&hid_endpoint(1)).await?; let ms_path = hid_endpoint(1);
let kb = runtime_support::open_hid_if_ready(&kb_path).await?;
let ms = runtime_support::open_hid_if_ready(&ms_path).await?;
#[cfg(not(coverage))] #[cfg(not(coverage))]
info!("✅ HID endpoints ready"); if kb.is_some() && ms.is_some() {
info!("✅ HID endpoints ready");
} else {
warn!("⌛ HID endpoints are not ready; relay will keep running and open them lazily");
}
Ok(Self { Ok(Self {
kb: Arc::new(Mutex::new(kb)), kb: Arc::new(Mutex::new(kb)),
@ -108,8 +114,8 @@ impl Handler {
} }
async fn reopen_hid(&self) -> anyhow::Result<()> { async fn reopen_hid(&self) -> anyhow::Result<()> {
let kb_new = runtime_support::open_with_retry(&hid_endpoint(0)).await?; let kb_new = runtime_support::open_hid_if_ready(&hid_endpoint(0)).await?;
let ms_new = runtime_support::open_with_retry(&hid_endpoint(1)).await?; let ms_new = runtime_support::open_hid_if_ready(&hid_endpoint(1)).await?;
*self.kb.lock().await = kb_new; *self.kb.lock().await = kb_new;
*self.ms.lock().await = ms_new; *self.ms.lock().await = ms_new;
Ok(()) Ok(())
@ -311,7 +317,7 @@ impl Handler {
) -> Result<Response<PasteReply>, Status> { ) -> Result<Response<PasteReply>, Status> {
let req = req.into_inner(); let req = req.into_inner();
let text = paste::decrypt(&req).map_err(|e| Status::unauthenticated(format!("{e}")))?; let text = paste::decrypt(&req).map_err(|e| Status::unauthenticated(format!("{e}")))?;
if let Err(e) = paste::type_text(self.kb.as_ref(), &text).await { if let Err(e) = paste::type_text(&self.kb, &hid_endpoint(0), &text).await {
return Ok(Response::new(PasteReply { return Ok(Response::new(PasteReply {
ok: false, ok: false,
error: format!("{e}"), error: format!("{e}"),
@ -528,6 +534,8 @@ impl Relay for Handler {
let (tx, rx) = tokio::sync::mpsc::channel(32); let (tx, rx) = tokio::sync::mpsc::channel(32);
let kb = self.kb.clone(); let kb = self.kb.clone();
let ms = self.ms.clone(); let ms = self.ms.clone();
let kb_path = hid_endpoint(0);
let ms_path = hid_endpoint(1);
let gadget = self.gadget.clone(); let gadget = self.gadget.clone();
let did_cycle = self.did_cycle.clone(); let did_cycle = self.did_cycle.clone();
let session_lease = self.capture_power.acquire_session().await; let session_lease = self.capture_power.acquire_session().await;
@ -537,7 +545,7 @@ impl Relay for Handler {
let _session_lease = session_lease; let _session_lease = session_lease;
let mut s = req.into_inner(); let mut s = req.into_inner();
while let Some(pkt) = s.next().await.transpose()? { while let Some(pkt) = s.next().await.transpose()? {
if let Err(e) = runtime_support::write_hid_report(&kb, &pkt.data).await { if let Err(e) = runtime_support::write_hid_report(&kb, &kb_path, &pkt.data).await {
if e.raw_os_error() == Some(libc::EAGAIN) { if e.raw_os_error() == Some(libc::EAGAIN) {
debug!(rpc_id, "⌨️ write would block (dropped)"); debug!(rpc_id, "⌨️ write would block (dropped)");
} else { } else {
@ -547,6 +555,8 @@ impl Relay for Handler {
gadget.clone(), gadget.clone(),
kb.clone(), kb.clone(),
ms.clone(), ms.clone(),
kb_path.clone(),
ms_path.clone(),
did_cycle.clone(), did_cycle.clone(),
) )
.await; .await;
@ -573,6 +583,8 @@ impl Relay for Handler {
let (tx, rx) = tokio::sync::mpsc::channel(1024); let (tx, rx) = tokio::sync::mpsc::channel(1024);
let ms = self.ms.clone(); let ms = self.ms.clone();
let kb = self.kb.clone(); let kb = self.kb.clone();
let kb_path = hid_endpoint(0);
let ms_path = hid_endpoint(1);
let gadget = self.gadget.clone(); let gadget = self.gadget.clone();
let did_cycle = self.did_cycle.clone(); let did_cycle = self.did_cycle.clone();
let session_lease = self.capture_power.acquire_session().await; let session_lease = self.capture_power.acquire_session().await;
@ -581,7 +593,7 @@ impl Relay for Handler {
let _session_lease = session_lease; let _session_lease = session_lease;
let mut s = req.into_inner(); let mut s = req.into_inner();
while let Some(pkt) = s.next().await.transpose()? { while let Some(pkt) = s.next().await.transpose()? {
if let Err(e) = runtime_support::write_hid_report(&ms, &pkt.data).await { if let Err(e) = runtime_support::write_hid_report(&ms, &ms_path, &pkt.data).await {
if e.raw_os_error() == Some(libc::EAGAIN) { if e.raw_os_error() == Some(libc::EAGAIN) {
debug!(rpc_id, "🖱️ write would block (dropped)"); debug!(rpc_id, "🖱️ write would block (dropped)");
} else { } else {
@ -591,6 +603,8 @@ impl Relay for Handler {
gadget.clone(), gadget.clone(),
kb.clone(), kb.clone(),
ms.clone(), ms.clone(),
kb_path.clone(),
ms_path.clone(),
did_cycle.clone(), did_cycle.clone(),
) )
.await; .await;
@ -753,7 +767,7 @@ impl Relay for Handler {
tokio::spawn(async move { tokio::spawn(async move {
let mut s = req.into_inner(); let mut s = req.into_inner();
while let Some(pkt) = s.next().await.transpose()? { while let Some(pkt) = s.next().await.transpose()? {
let _ = runtime_support::write_hid_report(&kb, &pkt.data).await; let _ = runtime_support::write_hid_report(&kb, &hid_endpoint(0), &pkt.data).await;
tx.send(Ok(pkt)).await.ok(); tx.send(Ok(pkt)).await.ok();
if !report_delay.is_zero() { if !report_delay.is_zero() {
tokio::time::sleep(report_delay).await; tokio::time::sleep(report_delay).await;
@ -775,7 +789,7 @@ impl Relay for Handler {
tokio::spawn(async move { tokio::spawn(async move {
let mut s = req.into_inner(); let mut s = req.into_inner();
while let Some(pkt) = s.next().await.transpose()? { while let Some(pkt) = s.next().await.transpose()? {
let _ = runtime_support::write_hid_report(&ms, &pkt.data).await; let _ = runtime_support::write_hid_report(&ms, &hid_endpoint(1), &pkt.data).await;
tx.send(Ok(pkt)).await.ok(); tx.send(Ok(pkt)).await.ok();
} }
Ok::<(), Status>(()) Ok::<(), Status>(())

View File

@ -4,11 +4,10 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use chacha20poly1305::aead::{Aead, KeyInit}; use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use std::path::PathBuf; use std::{path::PathBuf, sync::Arc};
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::runtime_support;
use lesavka_common::hid::{append_char_reports, char_to_usage}; use lesavka_common::hid::{append_char_reports, char_to_usage};
use lesavka_common::lesavka::PasteRequest; use lesavka_common::lesavka::PasteRequest;
use lesavka_common::paste::decode_shared_key; use lesavka_common::paste::decode_shared_key;
@ -41,7 +40,11 @@ pub fn decrypt(req: &PasteRequest) -> Result<String> {
/// supported character up to the configured maximum. /// supported character up to the configured maximum.
/// Why: paste injection must rate-limit itself so slower hosts do not drop /// Why: paste injection must rate-limit itself so slower hosts do not drop
/// HID reports under bursty clipboard loads. /// HID reports under bursty clipboard loads.
pub async fn type_text(kb: &Mutex<File>, text: &str) -> Result<()> { pub async fn type_text(
kb: &Arc<Mutex<Option<tokio::fs::File>>>,
hid_path: &str,
text: &str,
) -> Result<()> {
let max = std::env::var("LESAVKA_PASTE_MAX") let max = std::env::var("LESAVKA_PASTE_MAX")
.ok() .ok()
.and_then(|v| v.parse::<usize>().ok()) .and_then(|v| v.parse::<usize>().ok())
@ -59,12 +62,11 @@ pub async fn type_text(kb: &Mutex<File>, text: &str) -> Result<()> {
); );
} }
let mut kb = kb.lock().await;
for c in text.chars().take(max) { for c in text.chars().take(max) {
let mut reports = Vec::with_capacity(4); let mut reports = Vec::with_capacity(4);
if append_char_reports(&mut reports, c) { if append_char_reports(&mut reports, c) {
for report in reports { for report in reports {
kb.write_all(&report).await?; runtime_support::write_hid_report(kb, hid_path, &report).await?;
if delay_ms > 0 { if delay_ms > 0 {
tokio::time::sleep(delay).await; tokio::time::sleep(delay).await;
} }
@ -128,6 +130,7 @@ mod tests {
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use lesavka_common::lesavka::PasteRequest; use lesavka_common::lesavka::PasteRequest;
use serial_test::serial; use serial_test::serial;
use std::sync::Arc;
use temp_env::with_var; use temp_env::with_var;
use tempfile::tempdir; use tempfile::tempdir;
use tokio::fs::{File, OpenOptions}; use tokio::fs::{File, OpenOptions};
@ -183,8 +186,10 @@ mod tests {
.open(&path) .open(&path)
.await .await
.expect("open temp file"); .expect("open temp file");
let kb = Mutex::new(file); let kb = Arc::new(Mutex::new(Some(file)));
type_text(&kb, "A!?").await.expect("type text"); type_text(&kb, path.to_str().unwrap(), "A!?")
.await
.expect("type text");
let mut bytes = Vec::new(); let mut bytes = Vec::new();
let mut file = File::open(&path).await.expect("reopen temp file"); let mut file = File::open(&path).await.expect("reopen temp file");
@ -218,8 +223,8 @@ mod tests {
.open(&path) .open(&path)
.await .await
.expect("open temp file"); .expect("open temp file");
let kb = Mutex::new(file); let kb = Arc::new(Mutex::new(Some(file)));
let err = type_text(&kb, "pw🙂") let err = type_text(&kb, path.to_str().unwrap(), "pw🙂")
.await .await
.expect_err("unsupported char should fail"); .expect_err("unsupported char should fail");
assert!(err.to_string().contains("unsupported character")); assert!(err.to_string().contains("unsupported character"));

View File

@ -66,10 +66,7 @@ pub fn init_tracing() -> anyhow::Result<WorkerGuard> {
/// must wait for readiness instead of failing the whole process immediately. /// must wait for readiness instead of failing the whole process immediately.
#[cfg(coverage)] #[cfg(coverage)]
pub async fn open_with_retry(path: &str) -> anyhow::Result<tokio::fs::File> { pub async fn open_with_retry(path: &str) -> anyhow::Result<tokio::fs::File> {
OpenOptions::new() open_hid_file(path)
.write(true)
.custom_flags(libc::O_NONBLOCK)
.open(path)
.await .await
.with_context(|| format!("opening {path}")) .with_context(|| format!("opening {path}"))
} }
@ -77,18 +74,16 @@ pub async fn open_with_retry(path: &str) -> anyhow::Result<tokio::fs::File> {
#[cfg(not(coverage))] #[cfg(not(coverage))]
pub async fn open_with_retry(path: &str) -> anyhow::Result<tokio::fs::File> { pub async fn open_with_retry(path: &str) -> anyhow::Result<tokio::fs::File> {
for attempt in 1..=200 { for attempt in 1..=200 {
match OpenOptions::new() match open_hid_file(path).await {
.write(true)
.custom_flags(libc::O_NONBLOCK)
.open(path)
.await
{
Ok(file) => { Ok(file) => {
info!("✅ {path} opened on attempt #{attempt}"); info!("✅ {path} opened on attempt #{attempt}");
return Ok(file); return Ok(file);
} }
Err(error) if error.raw_os_error() == Some(libc::EBUSY) => { Err(error)
trace!("⏳ {path} busy… retry #{attempt}"); if hid_endpoint_open_is_temporarily_unavailable(error.raw_os_error())
|| error.raw_os_error() == Some(libc::EBUSY) =>
{
trace!("⏳ {path} unavailable ({error})… retry #{attempt}");
tokio::time::sleep(Duration::from_millis(50)).await; tokio::time::sleep(Duration::from_millis(50)).await;
} }
Err(error) => return Err(error).with_context(|| format!("opening {path}")), Err(error) => return Err(error).with_context(|| format!("opening {path}")),
@ -98,6 +93,36 @@ pub async fn open_with_retry(path: &str) -> anyhow::Result<tokio::fs::File> {
Err(anyhow::anyhow!("timeout waiting for {path}")) Err(anyhow::anyhow!("timeout waiting for {path}"))
} }
async fn open_hid_file(path: &str) -> std::io::Result<tokio::fs::File> {
OpenOptions::new()
.write(true)
.custom_flags(libc::O_NONBLOCK)
.open(path)
.await
}
pub async fn open_hid_if_ready(path: &str) -> anyhow::Result<Option<tokio::fs::File>> {
match open_hid_file(path).await {
Ok(file) => {
info!("✅ {path} opened");
Ok(Some(file))
}
Err(error) if hid_endpoint_open_is_temporarily_unavailable(error.raw_os_error()) => {
warn!("⌛ {path} is not ready yet ({error}); relay will retry lazily");
Ok(None)
}
Err(error) => Err(error).with_context(|| format!("opening {path}")),
}
}
#[must_use]
pub fn hid_endpoint_open_is_temporarily_unavailable(code: Option<i32>) -> bool {
matches!(
code,
Some(libc::ENOENT) | Some(libc::ENODEV) | Some(libc::ENXIO)
)
}
/// Check whether gadget auto-recovery is enabled. /// Check whether gadget auto-recovery is enabled.
/// ///
/// Inputs: none. /// Inputs: none.
@ -120,7 +145,7 @@ pub fn should_recover_hid_error(code: Option<i32>) -> bool {
matches!( matches!(
code, code,
Some(libc::ENOTCONN) | Some(libc::ESHUTDOWN) | Some(libc::EPIPE) Some(libc::ENOTCONN) | Some(libc::ESHUTDOWN) | Some(libc::EPIPE)
) ) || hid_endpoint_open_is_temporarily_unavailable(code)
} }
/// Recover the HID endpoints after a transport failure. /// Recover the HID endpoints after a transport failure.
@ -134,8 +159,10 @@ pub fn should_recover_hid_error(code: Option<i32>) -> bool {
pub async fn recover_hid_if_needed( pub async fn recover_hid_if_needed(
err: &std::io::Error, err: &std::io::Error,
gadget: UsbGadget, gadget: UsbGadget,
kb: Arc<Mutex<tokio::fs::File>>, kb: Arc<Mutex<Option<tokio::fs::File>>>,
ms: Arc<Mutex<tokio::fs::File>>, ms: Arc<Mutex<Option<tokio::fs::File>>>,
_kb_path: String,
_ms_path: String,
did_cycle: Arc<AtomicBool>, did_cycle: Arc<AtomicBool>,
) { ) {
let code = err.raw_os_error(); let code = err.raw_os_error();
@ -166,8 +193,10 @@ pub async fn recover_hid_if_needed(
pub async fn recover_hid_if_needed( pub async fn recover_hid_if_needed(
err: &std::io::Error, err: &std::io::Error,
gadget: UsbGadget, gadget: UsbGadget,
kb: Arc<Mutex<tokio::fs::File>>, kb: Arc<Mutex<Option<tokio::fs::File>>>,
ms: Arc<Mutex<tokio::fs::File>>, ms: Arc<Mutex<Option<tokio::fs::File>>>,
kb_path: String,
ms_path: String,
did_cycle: Arc<AtomicBool>, did_cycle: Arc<AtomicBool>,
) { ) {
let code = err.raw_os_error(); let code = err.raw_os_error();
@ -198,8 +227,8 @@ pub async fn recover_hid_if_needed(
} }
if let Err(error) = async { if let Err(error) = async {
let kb_new = open_with_retry("/dev/hidg0").await?; let kb_new = open_hid_if_ready(&kb_path).await?;
let ms_new = open_with_retry("/dev/hidg1").await?; let ms_new = open_hid_if_ready(&ms_path).await?;
*kb.lock().await = kb_new; *kb.lock().await = kb_new;
*ms.lock().await = ms_new; *ms.lock().await = ms_new;
Ok::<(), anyhow::Error>(()) Ok::<(), anyhow::Error>(())
@ -496,16 +525,28 @@ pub fn next_stream_id() -> u64 {
/// stalls without blocking the stream task indefinitely. /// stalls without blocking the stream task indefinitely.
#[cfg(coverage)] #[cfg(coverage)]
pub async fn write_hid_report( pub async fn write_hid_report(
dev: &Arc<Mutex<tokio::fs::File>>, dev: &Arc<Mutex<Option<tokio::fs::File>>>,
path: &str,
data: &[u8], data: &[u8],
) -> std::io::Result<()> { ) -> std::io::Result<()> {
let mut file = dev.lock().await; let mut file = dev.lock().await;
file.write_all(data).await if file.is_none() {
*file = Some(open_hid_file(path).await?);
}
if let Some(file) = file.as_mut() {
file.write_all(data).await
} else {
Err(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"HID endpoint is not open",
))
}
} }
#[cfg(not(coverage))] #[cfg(not(coverage))]
pub async fn write_hid_report( pub async fn write_hid_report(
dev: &Arc<Mutex<tokio::fs::File>>, dev: &Arc<Mutex<Option<tokio::fs::File>>>,
path: &str,
data: &[u8], data: &[u8],
) -> std::io::Result<()> { ) -> std::io::Result<()> {
let attempts = std::env::var("LESAVKA_HID_WRITE_RETRIES") let attempts = std::env::var("LESAVKA_HID_WRITE_RETRIES")
@ -521,7 +562,22 @@ pub async fn write_hid_report(
let mut last_error: Option<std::io::Error> = None; let mut last_error: Option<std::io::Error> = None;
for attempt in 0..attempts { for attempt in 0..attempts {
let mut file = dev.lock().await; let mut file = dev.lock().await;
match file.write_all(data).await { if file.is_none() {
match open_hid_file(path).await {
Ok(opened) => {
info!("✅ {path} opened lazily");
*file = Some(opened);
}
Err(error) => return Err(error),
}
}
let Some(file_handle) = file.as_mut() else {
return Err(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"HID endpoint is not open",
));
};
match file_handle.write_all(data).await {
Ok(()) => return Ok(()), Ok(()) => return Ok(()),
Err(error) Err(error)
if error.kind() == std::io::ErrorKind::WouldBlock if error.kind() == std::io::ErrorKind::WouldBlock
@ -529,7 +585,12 @@ pub async fn write_hid_report(
{ {
last_error = Some(error); last_error = Some(error);
} }
Err(error) => return Err(error), Err(error) => {
if should_recover_hid_error(error.raw_os_error()) {
*file = None;
}
return Err(error);
}
} }
drop(file); drop(file);
tokio::time::sleep(Duration::from_millis((attempt as u64 + 1) * base_delay_ms)).await; tokio::time::sleep(Duration::from_millis((attempt as u64 + 1) * base_delay_ms)).await;
@ -661,9 +722,9 @@ mod tests {
.open(tmp.path()) .open(tmp.path())
.await .await
.expect("open temp file"); .expect("open temp file");
let shared = Arc::new(Mutex::new(file)); let shared = Arc::new(Mutex::new(Some(file)));
write_hid_report(&shared, &[1, 2, 3, 4]) write_hid_report(&shared, tmp.path().to_str().unwrap(), &[1, 2, 3, 4])
.await .await
.expect("write succeeds"); .expect("write succeeds");

View File

@ -44,8 +44,8 @@ mod server_main_binary {
( (
dir, dir,
Handler { Handler {
kb: std::sync::Arc::new(tokio::sync::Mutex::new(kb)), kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
ms: std::sync::Arc::new(tokio::sync::Mutex::new(ms)), ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
gadget: UsbGadget::new("lesavka"), gadget: UsbGadget::new("lesavka"),
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
camera_rt: std::sync::Arc::new(CameraRuntime::new()), camera_rt: std::sync::Arc::new(CameraRuntime::new()),

View File

@ -86,8 +86,8 @@ mod server_main_binary_extra {
( (
dir, dir,
Handler { Handler {
kb: std::sync::Arc::new(tokio::sync::Mutex::new(kb)), kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
ms: std::sync::Arc::new(tokio::sync::Mutex::new(ms)), ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
gadget: UsbGadget::new("lesavka"), gadget: UsbGadget::new("lesavka"),
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
camera_rt: std::sync::Arc::new(CameraRuntime::new()), camera_rt: std::sync::Arc::new(CameraRuntime::new()),
@ -455,8 +455,8 @@ mod server_main_binary_extra {
.expect("open hidg1"), .expect("open hidg1"),
); );
let handler = Handler { let handler = Handler {
kb: std::sync::Arc::new(tokio::sync::Mutex::new(kb)), kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
ms: std::sync::Arc::new(tokio::sync::Mutex::new(ms)), ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
gadget: UsbGadget::new("lesavka"), gadget: UsbGadget::new("lesavka"),
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
camera_rt: std::sync::Arc::new(CameraRuntime::new()), camera_rt: std::sync::Arc::new(CameraRuntime::new()),

View File

@ -45,8 +45,8 @@ mod server_main_rpc {
( (
dir, dir,
Handler { Handler {
kb: std::sync::Arc::new(tokio::sync::Mutex::new(kb)), kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
ms: std::sync::Arc::new(tokio::sync::Mutex::new(ms)), ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
gadget: UsbGadget::new("lesavka"), gadget: UsbGadget::new("lesavka"),
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
camera_rt: std::sync::Arc::new(CameraRuntime::new()), camera_rt: std::sync::Arc::new(CameraRuntime::new()),
@ -399,8 +399,8 @@ mod server_main_rpc {
Some(dir.path().join("cfg").to_string_lossy().to_string()), Some(dir.path().join("cfg").to_string_lossy().to_string()),
|| { || {
let handler = Handler { let handler = Handler {
kb: std::sync::Arc::new(tokio::sync::Mutex::new(kb)), kb: std::sync::Arc::new(tokio::sync::Mutex::new(Some(kb))),
ms: std::sync::Arc::new(tokio::sync::Mutex::new(ms)), ms: std::sync::Arc::new(tokio::sync::Mutex::new(Some(ms))),
gadget: UsbGadget::new("lesavka"), gadget: UsbGadget::new("lesavka"),
did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new( did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
false, false,

View File

@ -187,8 +187,8 @@ fn runtime_recover_hid_ignores_non_transport_errors() {
.await .await
.expect("open temp ms"); .expect("open temp ms");
let kb = Arc::new(Mutex::new(kb)); let kb = Arc::new(Mutex::new(Some(kb)));
let ms = Arc::new(Mutex::new(ms)); let ms = Arc::new(Mutex::new(Some(ms)));
let did_cycle = Arc::new(AtomicBool::new(false)); let did_cycle = Arc::new(AtomicBool::new(false));
let err = std::io::Error::from_raw_os_error(libc::EAGAIN); let err = std::io::Error::from_raw_os_error(libc::EAGAIN);
@ -197,6 +197,8 @@ fn runtime_recover_hid_ignores_non_transport_errors() {
UsbGadget::new("lesavka"), UsbGadget::new("lesavka"),
kb, kb,
ms, ms,
kb_tmp.path().to_string_lossy().to_string(),
ms_tmp.path().to_string_lossy().to_string(),
did_cycle.clone(), did_cycle.clone(),
) )
.await; .await;
@ -227,8 +229,8 @@ fn runtime_recover_hid_short_circuits_when_cycle_already_in_progress() {
.await .await
.expect("open temp ms"); .expect("open temp ms");
let kb = Arc::new(Mutex::new(kb)); let kb = Arc::new(Mutex::new(Some(kb)));
let ms = Arc::new(Mutex::new(ms)); let ms = Arc::new(Mutex::new(Some(ms)));
let did_cycle = Arc::new(AtomicBool::new(true)); let did_cycle = Arc::new(AtomicBool::new(true));
let err = std::io::Error::from_raw_os_error(libc::EPIPE); let err = std::io::Error::from_raw_os_error(libc::EPIPE);
@ -237,6 +239,8 @@ fn runtime_recover_hid_short_circuits_when_cycle_already_in_progress() {
UsbGadget::new("lesavka"), UsbGadget::new("lesavka"),
kb, kb,
ms, ms,
kb_tmp.path().to_string_lossy().to_string(),
ms_tmp.path().to_string_lossy().to_string(),
did_cycle.clone(), did_cycle.clone(),
) )
.await; .await;
@ -268,8 +272,8 @@ fn runtime_recover_hid_resets_cycle_flag_after_async_recovery_path() {
.await .await
.expect("open temp ms"); .expect("open temp ms");
let kb = Arc::new(Mutex::new(kb)); let kb = Arc::new(Mutex::new(Some(kb)));
let ms = Arc::new(Mutex::new(ms)); let ms = Arc::new(Mutex::new(Some(ms)));
let did_cycle = Arc::new(AtomicBool::new(false)); let did_cycle = Arc::new(AtomicBool::new(false));
let err = std::io::Error::from_raw_os_error(libc::EPIPE); let err = std::io::Error::from_raw_os_error(libc::EPIPE);
@ -278,6 +282,8 @@ fn runtime_recover_hid_resets_cycle_flag_after_async_recovery_path() {
UsbGadget::new("lesavka"), UsbGadget::new("lesavka"),
kb.clone(), kb.clone(),
ms.clone(), ms.clone(),
kb_tmp.path().to_string_lossy().to_string(),
ms_tmp.path().to_string_lossy().to_string(),
did_cycle.clone(), did_cycle.clone(),
) )
.await; .await;
@ -316,8 +322,8 @@ fn runtime_recover_hid_attempts_cycle_when_enabled() {
.await .await
.expect("open temp ms"); .expect("open temp ms");
let kb = Arc::new(Mutex::new(kb)); let kb = Arc::new(Mutex::new(Some(kb)));
let ms = Arc::new(Mutex::new(ms)); let ms = Arc::new(Mutex::new(Some(ms)));
let did_cycle = Arc::new(AtomicBool::new(false)); let did_cycle = Arc::new(AtomicBool::new(false));
let err = std::io::Error::from_raw_os_error(libc::EPIPE); let err = std::io::Error::from_raw_os_error(libc::EPIPE);
@ -326,6 +332,8 @@ fn runtime_recover_hid_attempts_cycle_when_enabled() {
UsbGadget::new("lesavka"), UsbGadget::new("lesavka"),
kb.clone(), kb.clone(),
ms.clone(), ms.clone(),
kb_tmp.path().to_string_lossy().to_string(),
ms_tmp.path().to_string_lossy().to_string(),
did_cycle.clone(), did_cycle.clone(),
) )
.await; .await;