From a7b1923ca21295907ef233fb9ff305ba087c47da Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sun, 26 Apr 2026 18:44:16 -0400 Subject: [PATCH] fix(web/connections): probe = "online" not "configured"; flip status to "configuring" before DB open; await transport disconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reconnect-flow bugs: 1. probeConnection returned "configured" for serial / bluetooth when the browser had stored permission for the device — but permission ≠ a configured Meshtastic device. The card showed "connected" and a Disconnect button before the user had ever clicked Connect. Probe now returns "online" (= "available, click to connect"), matching the HTTP path. "configured" is reserved for the onConfigComplete callback. 2. setupMeshDevice flipped status from "connecting" → "configuring" AFTER awaiting buildMeshDevice, which opens the OPFS DB. If the DB open stalled (multi-tab contention, slow first-time init), the card sat at "connecting" indefinitely. Move the status flip ahead of the persistence await — UI shows "configuring" while persistence is spinning up. 3. teardown's `device?.connection?.disconnect()` returned a Promise that was never awaited, so the underlying transport's port.close() could race the next port.open() on a fast disconnect → reconnect. Make teardown async, await disconnect, propagate via async removeConnection / disconnect callers. Plus: connect() catch block logs the underlying error before turning it into a status update, so the actual failure shows up in console even when the toast text is generic. --- apps/web/src/core/connections/transports.ts | 8 ++++-- .../src/pages/Connections/useConnections.ts | 25 +++++++++++++------ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/apps/web/src/core/connections/transports.ts b/apps/web/src/core/connections/transports.ts index be009f56..962bd1bf 100644 --- a/apps/web/src/core/connections/transports.ts +++ b/apps/web/src/core/connections/transports.ts @@ -188,7 +188,11 @@ export async function probeConnection( } ).getDevices?.(); const hasPermission = known?.some((d: BluetoothDevice) => d.id === conn.deviceId); - return hasPermission ? "configured" : "disconnected"; + // Permission granted ≠ device configured. The card surfaces "online" + // (i.e. "available, click to connect") so the user explicitly opts + // into the configure handshake. "configured" is reserved for when + // the firmware has actually replied with config-complete. + return hasPermission ? "online" : "disconnected"; } catch { return "disconnected"; } @@ -210,7 +214,7 @@ export async function probeConnection( ).getInfo?.() ?? {}; return info.usbVendorId === conn.usbVendorId && info.usbProductId === conn.usbProductId; }); - return hasPermission ? "configured" : "disconnected"; + return hasPermission ? "online" : "disconnected"; } catch { return "disconnected"; } diff --git a/apps/web/src/pages/Connections/useConnections.ts b/apps/web/src/pages/Connections/useConnections.ts index 8c6b9a8d..24beb4c0 100644 --- a/apps/web/src/pages/Connections/useConnections.ts +++ b/apps/web/src/pages/Connections/useConnections.ts @@ -49,7 +49,7 @@ export function useConnections() { [updateSavedConnection], ); - const teardown = useCallback((id: ConnectionId, conn?: Connection) => { + const teardown = useCallback(async (id: ConnectionId, conn?: Connection) => { stopHeartbeat(id); configSubscriptions.get(id)?.(); configSubscriptions.delete(id); @@ -57,7 +57,10 @@ export function useConnections() { if (conn?.meshDeviceId) { const device = useDeviceStore.getState().getDevice(conn.meshDeviceId); try { - device?.connection?.disconnect(); + // Await the underlying transport's close so the port is fully + // released before the user clicks reconnect — otherwise port.open() + // can race the close and throw "port already open". + await device?.connection?.disconnect(); } catch {} } closeTransport(cachedTransports.get(id)); @@ -65,9 +68,9 @@ export function useConnections() { }, []); const removeConnection = useCallback( - (id: ConnectionId) => { + async (id: ConnectionId) => { const conn = connections.find((c) => c.id === id); - teardown(id, conn); + await teardown(id, conn); if (conn?.meshDeviceId) { try { useDeviceStore.getState().removeDevice(conn.meshDeviceId); @@ -104,6 +107,14 @@ export function useConnections() { deviceId = deviceId ?? randId(); const device = addDevice(deviceId); + + // Flip status to "configuring" up front. buildMeshDevice awaits the + // OPFS DB open which can take a beat (or stall under multi-tab + // contention); we don't want the UI looking stuck at "connecting" + // while persistence is spinning up. + device.setConnectionPhase("configuring"); + updateStatus(id, "configuring"); + const meshDevice = await buildMeshDevice(id, deviceId, transport); if (!meshRegistry.has(id)) { @@ -128,9 +139,6 @@ export function useConnections() { }); configSubscriptions.set(id, unsubConfigComplete); - device.setConnectionPhase("configuring"); - updateStatus(id, "configuring"); - meshDevice .configure() .then(() => meshDevice.heartbeat().then(() => startConfigHeartbeat(id, meshDevice))) @@ -175,6 +183,7 @@ export function useConnections() { }); return true; } catch (err) { + console.error("[useConnections] connect failed:", err); const message = err instanceof Error ? err.message : String(err); updateStatus(id, "error", message); return false; @@ -188,7 +197,7 @@ export function useConnections() { const conn = connections.find((c) => c.id === id); if (!conn) return; try { - teardown(id, conn); + await teardown(id, conn); if (conn.meshDeviceId) { const device = useDeviceStore.getState().getDevice(conn.meshDeviceId); if (device) {