From a32e40da3118eed3f1b34d3a4d8ecb6217837d34 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 26 Jul 2026 07:19:54 -0400 Subject: [PATCH] fix(mesh): stop probing one physical radio as two devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /dev/mesh-radio is a udev symlink to a ttyUSB*/ttyACM* node that is also in SERIAL_CANDIDATES, so one board was detected, probed and DTR/RTS-reset twice per reconnect cycle (and shown twice in the UI): - detect_serial_devices() dedupes candidates by canonical path, keeping the stable /dev/mesh-radio name - auto-detect fallback skips the preferred path it just probed this cycle instead of immediately resetting the same board again - probe_device's active-session guard compares canonical paths, so a probe via the alias can no longer open the tty the live session holds Together with the 2s boot-settle and stable-session backoff gate, this takes a plugged-in CP2102/ESP32 board from up to 9 reset events per cycle at a permanent 5s retry floor down to one probe sequence per backoff window — enough for the radio to actually finish booting. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/mesh/listener/session.rs | 19 +++++++++++++++++-- core/archipelago/src/mesh/mod.rs | 14 ++++++++++++-- core/archipelago/src/mesh/serial.rs | 16 ++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index 9db19b43..a5d3a92a 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -269,10 +269,23 @@ async fn auto_detect_and_open( our_ed_pubkey_hex: &str, our_x25519_pubkey_hex: &str, device_kind: Option, + skip_path: Option<&str>, ) -> Result<(String, MeshRadioDevice, DeviceInfo)> { - let paths = super::super::serial::detect_serial_devices().await; + let mut paths = super::super::serial::detect_serial_devices().await; + // When falling back from a just-failed preferred path, don't probe that + // same device again in the same cycle — every open() toggles DTR/RTS, + // which resets ESP32-family boards, and back-to-back re-probes are what + // keeps a mid-boot board from ever finishing its boot. + if let Some(skip) = skip_path { + let canon = |p: &str| std::fs::canonicalize(p).unwrap_or_else(|_| p.into()); + let skip_canon = canon(skip); + paths.retain(|p| canon(p) != skip_canon); + } if paths.is_empty() { - anyhow::bail!("No serial devices found in /dev"); + anyhow::bail!(match skip_path { + Some(skip) => format!("No serial devices found in /dev besides {skip}, which was already probed this cycle"), + None => "No serial devices found in /dev".to_string(), + }); } for path in &paths { debug!(path = %path, "Probing for mesh radio device"); @@ -986,6 +999,7 @@ pub(super) async fn run_mesh_session( our_ed_pubkey_hex, our_x25519_pubkey_hex, device_kind, + Some(path), ) .await? } @@ -996,6 +1010,7 @@ pub(super) async fn run_mesh_session( our_ed_pubkey_hex, our_x25519_pubkey_hex, device_kind, + None, ) .await? }; diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index ae2d9389..54191ee7 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -1063,8 +1063,18 @@ impl MeshService { /// with the reconnect loop (whichever loses just retries). pub async fn probe_device(&self, path: &str) -> Result { let status = self.state.status.read().await; - if status.device_connected && status.device_path.as_deref() == Some(path) { - anyhow::bail!("{path} is the active mesh radio — already connected"); + if status.device_connected { + if let Some(active) = status.device_path.as_deref() { + // Compare canonical paths: /dev/mesh-radio is a symlink to the + // ttyUSB*/ttyACM* node, and a probe through the alias would + // still open the very tty the live session is holding. + let canon = |p: &str| { + std::fs::canonicalize(p).unwrap_or_else(|_| std::path::PathBuf::from(p)) + }; + if canon(active) == canon(path) { + anyhow::bail!("{path} is the active mesh radio — already connected"); + } + } } drop(status); listener::probe_device(path).await diff --git a/core/archipelago/src/mesh/serial.rs b/core/archipelago/src/mesh/serial.rs index 32f0451b..dd7634bf 100644 --- a/core/archipelago/src/mesh/serial.rs +++ b/core/archipelago/src/mesh/serial.rs @@ -556,14 +556,30 @@ fn likely_non_mesh_serial_device(path: &str) -> bool { /// Scan for serial devices that could be Meshcore radios. /// Returns paths to existing serial device files. +/// +/// Candidates are deduplicated by canonical path: /dev/mesh-radio is a udev +/// symlink to a ttyUSB*/ttyACM* node that is ALSO in the candidate list, so +/// without this one physical board shows up (and gets probed, and gets its +/// DTR/RTS reset toggled) twice per cycle. The first candidate wins, which +/// keeps the stable /dev/mesh-radio name when the symlink exists. pub async fn detect_serial_devices() -> Vec { let mut devices = Vec::new(); + let mut seen_canonical: Vec = Vec::new(); for path in SERIAL_CANDIDATES { if tokio::fs::metadata(path).await.is_ok() { if likely_non_mesh_serial_device(path) { debug!(path = %path, "Skipping known non-mesh serial device"); continue; } + let canonical = tokio::fs::canonicalize(path) + .await + .unwrap_or_else(|_| std::path::PathBuf::from(path)); + if seen_canonical.contains(&canonical) { + debug!(path = %path, canonical = %canonical.display(), + "Skipping alias of an already-detected serial device"); + continue; + } + seen_canonical.push(canonical); devices.push(path.to_string()); } }