From f394c020558b802053a50ff27fa17c8f174f910a Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 6 Aug 2026 09:55:52 -0400 Subject: [PATCH] =?UTF-8?q?fix(mesh):=20ride=20out=20the=20radio=20restart?= =?UTF-8?q?=20during=20apply=20=E2=80=94=20no=20false=20errors,=20no=20set?= =?UTF-8?q?up=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applying RF settings deliberately restarts the radio daemon (~15-20s). Two things treated that healthy, expected gap as a fault (operator, 2026-08-06): - radio_state was single-shot: a query landing inside the restart window reported "The radio daemon did not answer the state query" for a restart that was working correctly. It now retries for ~30s and says the radio is restarting while it waits. A real device-level refusal (not an RNode) still returns immediately. - The device-setup modal auto-opens for any detected-but-unconnected port, so the restart looked like a newly plugged stick and interrupted the apply. Apply and Reboot now suppress auto-detect for 90s via mesh.suppressDeviceDetect(). Co-Authored-By: Claude Fable 5 --- core/archipelago/src/mesh/mod.rs | 59 +++++++++++++++------ neode-ui/src/stores/mesh.ts | 11 ++++ neode-ui/src/views/mesh/MeshDevicePanel.vue | 5 ++ 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 629bae36..db40868f 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -2155,22 +2155,51 @@ impl MeshService { /// interface including the radio-confirmed r_* parameters. The LoRa /// settings panel's source for "what is the device actually running". pub async fn radio_state(&self) -> Result { - let status = self.state.status.read().await; - if !status.device_connected { - anyhow::bail!("No mesh device connected. Check USB connection."); + // Retry across a reconnect window. Applying settings deliberately + // restarts the radio daemon (~15s), and the session is legitimately + // absent while it comes back — a single-shot query inside that window + // reported "the daemon did not answer" for what is a healthy, + // in-progress restart (operator, 2026-08-06). + const ATTEMPTS: u32 = 6; + let mut last_err = anyhow::anyhow!("No mesh device connected. Check USB connection."); + for attempt in 0..ATTEMPTS { + if attempt > 0 { + tokio::time::sleep(std::time::Duration::from_secs(4)).await; + } + if !self.state.status.read().await.device_connected { + last_err = anyhow::anyhow!( + "The radio is not connected right now — if settings were just applied it \ + is restarting and comes back within about 20 seconds." + ); + continue; + } + let (tx, rx) = tokio::sync::oneshot::channel(); + if self + .state + .send_cmd(listener::MeshCommand::QueryRadioState { reply: tx }) + .await + .is_err() + { + last_err = anyhow::anyhow!("Mesh listener not running"); + continue; + } + match tokio::time::timeout(std::time::Duration::from_secs(10), rx).await { + Ok(Ok(Ok(state))) => return Ok(state), + Ok(Ok(Err(e))) => { + // A real device-level refusal (e.g. not an RNode radio) — + // retrying cannot change it. + return Err(anyhow::anyhow!(e)); + } + Ok(Err(_)) => { + last_err = + anyhow::anyhow!("Mesh session ended before the state query completed") + } + Err(_) => { + last_err = anyhow::anyhow!("The radio daemon did not answer the state query") + } + } } - drop(status); - - let (tx, rx) = tokio::sync::oneshot::channel(); - self.state - .send_cmd(listener::MeshCommand::QueryRadioState { reply: tx }) - .await - .map_err(|_| anyhow::anyhow!("Mesh listener not running"))?; - let state = tokio::time::timeout(std::time::Duration::from_secs(10), rx) - .await - .map_err(|_| anyhow::anyhow!("The radio daemon did not answer the state query"))? - .map_err(|_| anyhow::anyhow!("Mesh session ended before the state query completed"))?; - state.map_err(|e| anyhow::anyhow!(e)) + Err(last_err) } /// Current mesh-AI assistant settings (issue #50). diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index b8a80a56..db7379e6 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -356,9 +356,19 @@ export const useMeshStore = defineStore('mesh', () => { // The modal waits for 2 sightings so it doesn't flash during the couple of // seconds an ordinary reconnect (same radio, transient blip) needs. const detectSightings = ref>({}) + /** Epoch-ms until which the device-setup modal must NOT auto-open: an + * operator-initiated radio restart (settings apply, Reboot Radio) takes + * the radio down for ~15-20s, and the modal treated that healthy, + * expected gap as "a new stick was plugged in" and interrupted the flow + * (operator, 2026-08-06). */ + const suppressDetectUntil = ref(0) + function suppressDeviceDetect(ms = 90_000) { + suppressDetectUntil.value = Date.now() + ms + } const undismissedDetectedDevices = computed(() => { const s = status.value if (!s) return [] + if (Date.now() < suppressDetectUntil.value) return [] return (s.detected_devices || []).filter(p => dismissedDetected.value[p] !== pluggedAt(s, p) && // The port the live session occupies is not a candidate… @@ -1148,6 +1158,7 @@ export const useMeshStore = defineStore('mesh', () => { latestBlockHeight, fetchStatus, undismissedDetectedDevices, + suppressDeviceDetect, dismissDetectedDevice, flashFlowPath, openFlashFlow, diff --git a/neode-ui/src/views/mesh/MeshDevicePanel.vue b/neode-ui/src/views/mesh/MeshDevicePanel.vue index 1345b321..6a01b979 100644 --- a/neode-ui/src/views/mesh/MeshDevicePanel.vue +++ b/neode-ui/src/views/mesh/MeshDevicePanel.vue @@ -13,6 +13,8 @@ async function handleReboot() { rebooting.value = true rebootError.value = null rebootMessage.value = null + // Same as apply: the radio goes away on purpose for ~15-20s. + mesh.suppressDeviceDetect() try { const res = await mesh.rebootRadio() // The backend now waits for the device's acknowledgement and says what @@ -101,6 +103,9 @@ async function loadRnodeConfig() { async function applyRnodeSettings() { rnodeApplying.value = true rnodeResult.value = null + // Applying deliberately restarts the radio daemon; without this the + // "new device detected" modal interrupts the flow mid-apply. + mesh.suppressDeviceDetect() try { const res = await mesh.applyRnodeConfig({ enabled: rnodeForm.value.enabled,