diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index a5d3a92a..f85d9170 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -372,6 +372,16 @@ pub struct DeviceProbe { pub max_contacts: Option, } +/// Serializes serial-port open sequences between the listener's session +/// opens and the RPC probe (`mesh.probe-device`). Linux happily double-opens +/// a tty, and two concurrent handshakes corrupt each other into silence — +/// observed live on .116 (2026-07-26): the kiosk browser's hot-swap +/// auto-probe collided with the listener's cycle on every backoff window, so +/// neither ever succeeded, and each collision's open() DTR/RTS-reset the +/// board again. The probe's retry-across-idle-gaps heuristic (5f01ec31) +/// narrowed but could not close the race; this closes it. +static PORT_OPEN_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + /// Probe a serial port for a mesh radio WITHOUT provisioning it: identify the /// firmware (same strict Reticulum→Meshcore→Meshtastic order as auto-detect, /// for the same RNode-wedging reason) and read what's currently configured on @@ -379,11 +389,10 @@ pub struct DeviceProbe { /// loop can pick the device up afterwards. Reticulum uses the bare KISS /// DETECT probe — no daemon spawn just to identify a stick. pub(crate) async fn probe_device(path: &str) -> Result { - // The listener's reconnect loop may hold this port for ~10s of every - // backoff cycle, and Linux happily double-opens a tty — two concurrent - // handshakes corrupt each other into silence (observed on framework-pt: - // every firmware "failed" while the listener was mid-cycle). Retry across - // the listener's idle gaps instead of failing on first collision. + // Retries kept even with PORT_OPEN_LOCK closing the double-open race: + // the board may still be mid-boot from a previous open's DTR/RTS reset, + // and a later attempt after a quiet gap can succeed where the first + // couldn't. let mut last_err = None; for attempt in 0..3u32 { if attempt > 0 { @@ -398,6 +407,7 @@ pub(crate) async fn probe_device(path: &str) -> Result { } async fn probe_device_once(path: &str) -> Result { + let _port_guard = PORT_OPEN_LOCK.lock().await; if super::super::reticulum::probe_rnode(path).await.is_ok() { return Ok(DeviceProbe { path: path.to_string(), @@ -976,6 +986,11 @@ pub(super) async fn run_mesh_session( // set, otherwise try the preferred serial path, falling back to // auto-detect. TCP mode is additive/dev-only; it never changes behavior // for existing serial/RNode deployments where `reticulum_tcp` is None. + // + // The whole open sequence runs under PORT_OPEN_LOCK so an RPC probe + // can't interleave its own handshakes on the same tty (see the lock's + // doc comment). Held only until the device is opened, then released. + let port_guard = PORT_OPEN_LOCK.lock().await; let (device_path, mut device, device_info) = if let Some(tcp_cfg) = &reticulum_tcp { open_reticulum_tcp(tcp_cfg, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await? } else if let Some(path) = preferred_path { @@ -1014,6 +1029,7 @@ pub(super) async fn run_mesh_session( ) .await? }; + drop(port_guard); // Update status {