diff --git a/core/archipelago/src/api/rpc/mesh/status.rs b/core/archipelago/src/api/rpc/mesh/status.rs index 2f004ffd..688ca399 100644 --- a/core/archipelago/src/api/rpc/mesh/status.rs +++ b/core/archipelago/src/api/rpc/mesh/status.rs @@ -101,12 +101,21 @@ impl RpcHandler { detected.iter().any(|d| d == &path), "{path} is not a detected mesh-radio candidate port" ); - let service = self.mesh_service.read().await; - let probe = match service.as_ref() { - Some(svc) => svc.probe_device(&path).await?, - // No mesh service yet (radio never enabled) — probe directly. - None => mesh::listener::probe_device(&path).await?, - }; + // Only hold the mesh_service lock long enough for the quick + // active-path guard check — NEVER across the actual probe, which + // can take 15-60s across its internal collision retries. Confirmed + // live 2026-07-23: holding this read lock for the full probe starved + // a concurrent firmware-flash job's MeshService::stop() (which needs + // the write lock) well past its own bounded timeout, surfacing as + // "Mesh listener did not release the serial port" even though + // stop() itself was fast. + { + let service = self.mesh_service.read().await; + if let Some(svc) = service.as_ref() { + svc.ensure_probe_allowed(&path).await?; + } + } + let probe = mesh::listener::probe_device(&path).await?; Ok(serde_json::to_value(probe)?) } diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index b2c237bf..dc6cc18b 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -547,6 +547,10 @@ pub fn spawn_mesh_listener( let mut shutdown = shutdown; let mut cmd_rx = cmd_rx; let mut reconnect_delay = RECONNECT_DELAY_INIT; + // Mutable so a successful auto-detect can pin the firmware kind for + // the rest of this listener's lifetime — see the pin-on-first-success + // block below for why. + let mut device_kind = device_kind; // Backlog #12 hot-swap re-binding: each run_mesh_session call already // builds a fresh device struct (contacts/current_region/etc. all // start empty), so per-device session state is naturally isolated @@ -618,6 +622,45 @@ pub fn spawn_mesh_listener( } } + // Pin the firmware kind after the first successful auto-detect. + // Confirmed live 2026-07-23: with device_kind left unpinned (e.g. + // after clearing a stale pin), EVERY reconnect re-runs the full + // Reticulum→Meshcore→Meshtastic auto-detect cascade — each + // candidate past the first does its own open() with the DTR/RTS + // reset both boards need, so a device correctly identified as + // Meshtastic still gets reset once for the failed Meshcore + // attempt before Meshtastic's own open() resets it again. That + // doubled the reset count on every single reconnect indefinitely, + // not just during initial detection. Once auto-detect has + // identified the device this listener is actually talking to, + // there's no reason to keep guessing on subsequent reconnects — + // pin it, both in this task's own loop (takes effect + // immediately) and on disk (survives a service restart). A + // genuine hot-swap to different firmware is still handled: the + // setup modal's `mesh.probe-device` always re-probes unpinned, + // and the flash flow already clears this pin on its own. + if device_kind.is_none() { + let detected = state.status.read().await.device_type; + if detected != super::types::DeviceType::Unknown { + device_kind = Some(detected); + match super::load_config(&data_dir).await { + Ok(mut cfg) if cfg.device_kind.is_none() => { + cfg.device_kind = Some(detected); + if let Err(e) = super::save_config(&data_dir, &cfg).await { + warn!("Failed to persist auto-detected device_kind: {}", e); + } else { + info!( + kind = %detected, + "Pinned auto-detected firmware kind to avoid repeated multi-protocol resets on reconnect" + ); + } + } + Ok(_) => {} + Err(e) => warn!("Failed to load mesh config to persist device_kind: {}", e), + } + } + } + // Update status to disconnected. device_type/firmware_version are // reset too — they were previously left holding the LAST radio's // identity, so after a hot-swap the UI showed the old firmware diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 3a549e86..4e5b201b 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -1064,19 +1064,17 @@ impl MeshService { self.state.peers.read().await.values().cloned().collect() } - /// Probe a serial port for a mesh radio without provisioning or keeping - /// it — powers the hot-swap "device detected" modal's current-details - /// view. Refuses to probe the port the live session currently occupies - /// (the probe would steal the serial port from under the session); a + /// Refuse to probe the port the live session currently occupies (the + /// probe would steal the serial port from under the session); a /// detected-but-not-connected port is fair game, accepting a benign race - /// with the reconnect loop (whichever loses just retries). - pub async fn probe_device(&self, path: &str) -> Result { + /// with the reconnect loop (whichever loses just retries). Split out from + /// the actual probe on purpose — see `probe_device`'s doc comment. + pub async fn ensure_probe_allowed(&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"); } - drop(status); - listener::probe_device(path).await + Ok(()) } /// Get message history. diff --git a/core/archipelago/src/mesh/serial.rs b/core/archipelago/src/mesh/serial.rs index 32f0451b..312c79f5 100644 --- a/core/archipelago/src/mesh/serial.rs +++ b/core/archipelago/src/mesh/serial.rs @@ -556,14 +556,38 @@ 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. +/// +/// Dedupes by canonical (symlink-resolved) target: `/dev/mesh-radio` is a +/// stable udev symlink to whatever `/dev/ttyUSB*`/`/dev/ttyACM*` node the +/// primary radio currently enumerates as, so both names always pointed at +/// the same candidate list entry and both passed this scan — confirmed live +/// 2026-07-23, this made an already-connected, working radio (connected via +/// its `/dev/mesh-radio` alias) simultaneously appear as a second, separate +/// "detected but unclaimed" device under its raw `/dev/ttyUSBn` name. The +/// hot-swap UI's active-session guard compares path strings, so it didn't +/// recognize the two aliases as the same port, showed the "device detected" +/// modal for a radio that was already set up, and probing it there opened +/// (and DTR/RTS-reset) the exact port the live session was mid-conversation +/// with — a continuous, UI-driven reset loop that only ran while that view +/// was open (matches the reported "stops when I leave, resumes when I come +/// back"). SERIAL_CANDIDATES lists `/dev/mesh-radio` first, so it wins the +/// dedup and is what's reported when both alias and target are present. pub async fn detect_serial_devices() -> Vec { let mut devices = Vec::new(); + let mut seen_real_paths = std::collections::HashSet::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 real_path = tokio::fs::canonicalize(path) + .await + .unwrap_or_else(|_| std::path::PathBuf::from(path)); + if !seen_real_paths.insert(real_path.clone()) { + debug!(path = %path, real_path = %real_path.display(), "Skipping duplicate alias for an already-listed device"); + continue; + } devices.push(path.to_string()); } }