fix(mesh): serialize serial-port opens between listener and RPC probe

Observed live on .116 after the dedup/backoff fixes: the kiosk browser's
hot-swap auto-probe (mesh.probe-device) still interleaved its handshakes
with the listener's open sequence on the same tty every backoff window —
Linux double-opens ttys silently, both handshakes corrupted each other,
and every collision's open() DTR/RTS-reset the board again. The retry
heuristic from 5f01ec31 narrowed but couldn't close the race.

A static PORT_OPEN_LOCK now covers the listener's whole device-open
sequence and each probe attempt, so exactly one prober touches the port
at a time. With this + the settle/backoff/dedup fixes, the .116 radio
completed its first MeshCore handshake in days (node-fa161601, 8
contacts) and the session has been stable since.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-26 08:05:42 -04:00
parent c8d0dda656
commit c0d34bd836

View File

@ -372,6 +372,16 @@ pub struct DeviceProbe {
pub max_contacts: Option<u16>, pub max_contacts: Option<u16>,
} }
/// 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 /// Probe a serial port for a mesh radio WITHOUT provisioning it: identify the
/// firmware (same strict Reticulum→Meshcore→Meshtastic order as auto-detect, /// firmware (same strict Reticulum→Meshcore→Meshtastic order as auto-detect,
/// for the same RNode-wedging reason) and read what's currently configured on /// 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 /// loop can pick the device up afterwards. Reticulum uses the bare KISS
/// DETECT probe — no daemon spawn just to identify a stick. /// DETECT probe — no daemon spawn just to identify a stick.
pub(crate) async fn probe_device(path: &str) -> Result<DeviceProbe> { pub(crate) async fn probe_device(path: &str) -> Result<DeviceProbe> {
// The listener's reconnect loop may hold this port for ~10s of every // Retries kept even with PORT_OPEN_LOCK closing the double-open race:
// backoff cycle, and Linux happily double-opens a tty — two concurrent // the board may still be mid-boot from a previous open's DTR/RTS reset,
// handshakes corrupt each other into silence (observed on framework-pt: // and a later attempt after a quiet gap can succeed where the first
// every firmware "failed" while the listener was mid-cycle). Retry across // couldn't.
// the listener's idle gaps instead of failing on first collision.
let mut last_err = None; let mut last_err = None;
for attempt in 0..3u32 { for attempt in 0..3u32 {
if attempt > 0 { if attempt > 0 {
@ -398,6 +407,7 @@ pub(crate) async fn probe_device(path: &str) -> Result<DeviceProbe> {
} }
async fn probe_device_once(path: &str) -> Result<DeviceProbe> { async fn probe_device_once(path: &str) -> Result<DeviceProbe> {
let _port_guard = PORT_OPEN_LOCK.lock().await;
if super::super::reticulum::probe_rnode(path).await.is_ok() { if super::super::reticulum::probe_rnode(path).await.is_ok() {
return Ok(DeviceProbe { return Ok(DeviceProbe {
path: path.to_string(), 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 // set, otherwise try the preferred serial path, falling back to
// auto-detect. TCP mode is additive/dev-only; it never changes behavior // auto-detect. TCP mode is additive/dev-only; it never changes behavior
// for existing serial/RNode deployments where `reticulum_tcp` is None. // 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 { 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? open_reticulum_tcp(tcp_cfg, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await?
} else if let Some(path) = preferred_path { } else if let Some(path) = preferred_path {
@ -1014,6 +1029,7 @@ pub(super) async fn run_mesh_session(
) )
.await? .await?
}; };
drop(port_guard);
// Update status // Update status
{ {