From 6b27fa16931d2ff16fd229d10735a6f0563313fa Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 04:27:28 -0600 Subject: [PATCH 1/7] chore: add FuzzyChaos to credits --- docs/credits.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/credits.md b/docs/credits.md index 4ccf2911d..66e7a084f 100644 --- a/docs/credits.md +++ b/docs/credits.md @@ -12,6 +12,7 @@ - [Soord](https://github.com/soord) - [WB3IHY](https://github.com/WB3IHY) - [Letark](https://github.com/Letark) - Apple code signing & notarization CI +- FuzzyChaos (ADL) - Donation for devices ## Colorado Mesh From 71e63008bb9171c4b1d3e3fbb1c00f446b328a9d Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 05:17:48 -0600 Subject: [PATCH 2/7] fix(meshcore): stop BLE auto-connect and disconnect races from aborting TCP Cancel ProtocolAutoConnectCoordinator when the user starts a manual connect, latch provisional reconnect params for the intended transport, ignore Noble BLE disconnect while connectType is TCP/serial, and accept OpenHop contact-dump FINs as configured instead of forcing a live-socket reconnect loop. --- .../components/ConnectionPanel.test.tsx | 58 ++++++++++ src/renderer/components/ConnectionPanel.tsx | 63 ++++++++++- ...eshcoreRuntime.initconn-rpc-order.test.tsx | 4 +- .../hooks/useProtocolRfAutoConnect.test.tsx | 27 +++++ .../hooks/useProtocolRfAutoConnect.ts | 51 ++++++++- .../lib/meshcore/meshcoreTcpInitBurst.test.ts | 12 +- .../lib/meshcore/meshcoreTcpInitBurst.ts | 10 +- .../lib/protocolRfAutoConnectGate.test.ts | 23 ++++ src/renderer/lib/protocolRfAutoConnectGate.ts | 20 ++++ .../useMeshcoreRuntime.reconnect.test.ts | 43 ++++++- src/renderer/runtime/useMeshcoreRuntime.ts | 107 +++++++++++++----- 11 files changed, 374 insertions(+), 44 deletions(-) create mode 100644 src/renderer/lib/protocolRfAutoConnectGate.test.ts create mode 100644 src/renderer/lib/protocolRfAutoConnectGate.ts diff --git a/src/renderer/components/ConnectionPanel.test.tsx b/src/renderer/components/ConnectionPanel.test.tsx index df7159b55..be8fbc206 100644 --- a/src/renderer/components/ConnectionPanel.test.tsx +++ b/src/renderer/components/ConnectionPanel.test.tsx @@ -1577,6 +1577,64 @@ describe('ConnectionPanel active-protocol-first BLE auto-connect', () => { } }); + it('cancels deferred meshcore BLE auto-connect when user cancels before primary settle completes', async () => { + const { restore } = mockMacNoblePlatform(); + const mcConnKey = 'mesh-client:lastConnection:meshcore'; + const mtConnKey = 'mesh-client:lastConnection:meshtastic'; + localStorage.setItem(protocolKey, 'meshtastic'); + localStorage.setItem('mesh-client:lastBleDevice:meshcore', 'meshcore-ble-device'); + localStorage.setItem('mesh-client:lastBleDevice:meshtastic', 'meshtastic-ble-device'); + localStorage.setItem( + mcConnKey, + JSON.stringify({ type: 'ble', bleDeviceId: 'meshcore-ble-device' }), + ); + localStorage.setItem( + mtConnKey, + JSON.stringify({ type: 'ble', bleDeviceId: 'meshtastic-ble-device' }), + ); + const onAutoConnect = vi.fn().mockResolvedValue(undefined); + const dualNoble = await import('../lib/meshcoreDualNobleBleInit'); + dualNoble.resetNobleBleConnectMutexForTests(); + dualNoble.initNobleBleDualRadioStartup(); + let releaseSettle!: () => void; + vi.spyOn(dualNoble, 'awaitNobleBlePrimaryAutoConnectSettled').mockImplementation( + () => + new Promise((resolve) => { + releaseSettle = resolve; + }), + ); + + const user = userEvent.setup(); + try { + render( + , + ); + + // Secondary BLE waits on Meshtastic — connecting UI exposes Cancel. + const cancelBtn = await screen.findByRole('button', { name: /^Cancel$/i }); + await user.click(cancelBtn); + + releaseSettle(); + await Promise.resolve(); + await Promise.resolve(); + expect(onAutoConnect).not.toHaveBeenCalled(); + } finally { + localStorage.removeItem(mcConnKey); + localStorage.removeItem(mtConnKey); + localStorage.removeItem('mesh-client:lastBleDevice:meshcore'); + localStorage.removeItem('mesh-client:lastBleDevice:meshtastic'); + dualNoble.resetNobleBleConnectMutexForTests(); + restore(); + } + }); + it('shows shared-peripheral notice when meshcore is active and targets the same BLE device as meshtastic', async () => { const { restore } = mockMacNoblePlatform(); const sharedId = 'shared-ble-peripheral'; diff --git a/src/renderer/components/ConnectionPanel.tsx b/src/renderer/components/ConnectionPanel.tsx index b453db2af..876d09f05 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -22,6 +22,7 @@ import { import { markMqttUserDisconnect } from '@/renderer/lib/mqttDisconnectIntent'; import { mqttUsesTls } from '@/renderer/lib/mqttTls'; import { parseTcpAddress } from '@/renderer/lib/parseTcpAddress'; +import { cancelProtocolRfAutoConnect } from '@/renderer/lib/protocolRfAutoConnectGate'; import { useRadioProvider } from '@/renderer/lib/radio/providerFactory'; import type { RfConnectAutomaticFn, RfConnectFn } from '@/renderer/lib/rfConnectionTypes'; import { isPairingRelatedError } from '@/shared/blePairingError'; @@ -704,6 +705,8 @@ export default function ConnectionPanel({ ); const autoConnectFiredRef = useRef(false); const autoConnectTimeoutRef = useRef | null>(null); + /** Set when the user starts a manual connect so deferred BLE auto-connect must not call onAutoConnect. */ + const autoConnectCancelRef = useRef(false); const isAutoConnectingRef = useRef(false); const [isAutoConnecting, setIsAutoConnecting] = useState(false); const [autoConnectBleTarget, setAutoConnectBleTarget] = useState(null); @@ -1164,6 +1167,18 @@ export default function ConnectionPanel({ }, [isAutoConnecting, lastConnection]); const handleConnect = useCallback(async () => { + // Cancel deferred dual-Noble BLE auto-connect so it cannot race prepareRfConnect against + // a manual TCP/serial/HTTP connect (orphan TCP socket + connectType flip). + // Panel-local autoConnectCancelRef is inert when suppressMountAutoConnect — also cancel the + // ProtocolAutoConnectCoordinator path. + autoConnectCancelRef.current = true; + cancelProtocolRfAutoConnect(protocol); + if (isAutoConnectingRef.current) { + console.debug('[ConnectionPanel] cancelling in-flight BLE auto-connect for manual connect'); + } + isAutoConnectingRef.current = false; + setIsAutoConnecting(false); + setAutoConnectBleTarget(null); if (autoConnectTimeoutRef.current) { clearTimeout(autoConnectTimeoutRef.current); autoConnectTimeoutRef.current = null; @@ -1283,6 +1298,8 @@ export default function ConnectionPanel({ }, [connectionType, activeHostAddress, onConnect, protocol, isLinux, t]); const handleCancelConnection = useCallback(async () => { + autoConnectCancelRef.current = true; + cancelProtocolRfAutoConnect(protocol); isAutoConnectingRef.current = false; setIsAutoConnecting(false); if (autoConnectTimeoutRef.current) { @@ -1444,6 +1461,7 @@ export default function ConnectionPanel({ } autoConnectFiredRef.current = true; + autoConnectCancelRef.current = false; const lastBleId = lc.bleDeviceId ?? loadLastBleDevice(protocol); @@ -1492,6 +1510,10 @@ export default function ConnectionPanel({ return false; } void (async () => { + if (autoConnectCancelRef.current) { + maybeNotifyPrimaryBleAutoConnectSettled(); + return; + } const bleTargetLabel = resolveBleAutoConnectLabel( lastBleId, lc, @@ -1506,6 +1528,9 @@ export default function ConnectionPanel({ setConnectionStage('connectionPanel.stageConnecting'); // Primary: notify secondary after the first connect attempt (not after scan fallback). await reconnectBleWithScan(protocol, lastBleId, () => { + if (autoConnectCancelRef.current) { + return Promise.reject(new DOMException('Auto-connect cancelled', 'AbortError')); + } const attempt = onAutoConnectRef.current('ble', undefined, undefined, lastBleId); if ( dualNobleBleBothRadiosConfigured() && @@ -1519,6 +1544,10 @@ export default function ConnectionPanel({ } return attempt; }); + if (autoConnectCancelRef.current) { + maybeNotifyPrimaryBleAutoConnectSettled(); + return; + } isAutoConnectingRef.current = false; setIsAutoConnecting(false); setConnecting(false); @@ -1568,11 +1597,26 @@ export default function ConnectionPanel({ : STAGE_WAITING_NOBLE_BLE_MESHCORE, ); await awaitNobleBlePrimaryAutoConnectSettled(POWER_RESUME_MESHCORE_MESHTASTIC_SETTLE_MS); + if (autoConnectCancelRef.current) { + console.debug( + `[ConnectionPanel] ${protocol} secondary BLE auto-connect cancelled after primary settle`, + ); + isAutoConnectingRef.current = false; + setIsAutoConnecting(false); + setAutoConnectBleTarget(null); + setConnecting(false); + setConnectionStage(''); + return; + } setConnectionStage('connectionPanel.stageConnecting'); - void reconnectBleWithScan(protocol, bleId, () => - onAutoConnectRef.current('ble', undefined, undefined, bleId), - ) + void reconnectBleWithScan(protocol, bleId, () => { + if (autoConnectCancelRef.current) { + return Promise.reject(new DOMException('Auto-connect cancelled', 'AbortError')); + } + return onAutoConnectRef.current('ble', undefined, undefined, bleId); + }) .then(() => { + if (autoConnectCancelRef.current) return; isAutoConnectingRef.current = false; setIsAutoConnecting(false); setConnecting(false); @@ -1827,10 +1871,16 @@ export default function ConnectionPanel({ if (!rfBusy || !isRendererNobleBlePlatform()) return; if (nobleBleMutexWait.waitingOnNobleBlePeer) { - const primary = nobleBleMutexWait.primaryProtocol; - if (primary === 'meshtastic') { + // Mutex peer wait: show who holds the mutex (`active`), not dual-radio primary. + // Using primaryProtocol alone made MeshCore show "Waiting for MeshCore… Meshtastic will + // connect" while MeshCore itself was queued behind Meshtastic GATT. + const waitingFor = + nobleBleMutexWait.waitingForPeer && nobleBleMutexWait.active + ? nobleBleMutexWait.active + : nobleBleMutexWait.primaryProtocol; + if (waitingFor === 'meshtastic') { setConnectionStage(STAGE_WAITING_NOBLE_BLE_MESHTASTIC); - } else if (primary === 'meshcore') { + } else if (waitingFor === 'meshcore') { setConnectionStage(STAGE_WAITING_NOBLE_BLE_MESHCORE); } return; @@ -1849,6 +1899,7 @@ export default function ConnectionPanel({ state.status, protocol, nobleBleMutexWait.waitingOnNobleBlePeer, + nobleBleMutexWait.waitingForPeer, nobleBleMutexWait.active, nobleBleMutexWait.primaryProtocol, connectionStage, diff --git a/src/renderer/hooks/useMeshcoreRuntime.initconn-rpc-order.test.tsx b/src/renderer/hooks/useMeshcoreRuntime.initconn-rpc-order.test.tsx index de44d11ae..8441d993e 100644 --- a/src/renderer/hooks/useMeshcoreRuntime.initconn-rpc-order.test.tsx +++ b/src/renderer/hooks/useMeshcoreRuntime.initconn-rpc-order.test.tsx @@ -502,7 +502,7 @@ describe('useMeshcoreRuntime initConn RPC ordering', () => { unmount(); }); - it('tcp: burst-complete dead bridge enters reconnecting and skips getChannels', async () => { + it('tcp: burst-complete dead bridge stays configured and skips getChannels', async () => { const callOrder: string[] = []; const gates = installSequentialInitGates(callOrder); const discCallbacks: (() => void)[] = []; @@ -541,7 +541,7 @@ describe('useMeshcoreRuntime initConn RPC ordering', () => { expect(callOrder).not.toContain('getChannels:end'); await waitFor(() => { - expect(result.current.state.status).toBe('reconnecting'); + expect(result.current.state.status).toBe('configured'); }); unmount(); }); diff --git a/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx b/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx index 527bac174..c46533acc 100644 --- a/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx +++ b/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx @@ -187,4 +187,31 @@ describe('useProtocolRfAutoConnect cold-start skip paths', () => { }); expect(mocks.awaitReticulumBleCoexistenceClear).toHaveBeenCalledTimes(1); }); + + it('aborts deferred MeshCore BLE auto-connect when manual connect cancels the gate', async () => { + const { cancelProtocolRfAutoConnect } = + await import('@/renderer/lib/protocolRfAutoConnectGate'); + mocks.loadLastConnection.mockReturnValue({ type: 'ble', bleDeviceId: 'meshcore-ble' }); + mocks.dualNobleBleBothRadiosConfigured.mockReturnValue(true); + mocks.isNobleBleDualRadioSecondary.mockReturnValue(true); + mocks.getNobleBleDualRadioPrimaryProtocol.mockReturnValue('meshtastic'); + mocks.awaitNobleBleProtocolSettle.mockImplementation(() => { + cancelProtocolRfAutoConnect('meshcore'); + return Promise.resolve(); + }); + const connectAutomatic = vi.fn().mockResolvedValue(undefined); + + renderHook(() => { + useProtocolRfAutoConnect({ + protocol: 'meshcore', + state: disconnected, + connectAutomatic, + }); + }); + + await waitFor(() => { + expect(mocks.awaitNobleBleProtocolSettle).toHaveBeenCalled(); + }); + expect(connectAutomatic).not.toHaveBeenCalled(); + }); }); diff --git a/src/renderer/hooks/useProtocolRfAutoConnect.ts b/src/renderer/hooks/useProtocolRfAutoConnect.ts index 79e7d15f8..99d12650d 100644 --- a/src/renderer/hooks/useProtocolRfAutoConnect.ts +++ b/src/renderer/hooks/useProtocolRfAutoConnect.ts @@ -18,6 +18,10 @@ import { meshcoreTargetsSharedMeshtasticBlePeripheral, notifyNobleBlePrimaryAutoConnectSettled, } from '@/renderer/lib/meshcoreDualNobleBleInit'; +import { + isProtocolRfAutoConnectCancelled, + resetProtocolRfAutoConnectCancel, +} from '@/renderer/lib/protocolRfAutoConnectGate'; import { awaitReticulumBleCoexistenceClear } from '@/renderer/lib/reticulum/reticulumStartupAutostartGate'; import type { RfConnectAutomaticFn } from '@/renderer/lib/rfConnectionTypes'; import { tryGetMeshcoreSession } from '@/renderer/lib/sessions/meshcoreSession'; @@ -109,11 +113,15 @@ export function useProtocolRfAutoConnect({ return; } firedRef.current = true; + // Fresh startup attempt — manual Connect may cancel later via cancelProtocolRfAutoConnect. + resetProtocolRfAutoConnectCancel(protocol); const lastBleId = lastConnection.bleDeviceId ?? loadLastBleDeviceId(protocol); const isLinux = window.electronAPI.getPlatform() === 'linux'; let cancelled = false; + const isCancelled = () => cancelled || isProtocolRfAutoConnectCancelled(protocol); + const clearAutoConnectTimeout = () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); @@ -145,17 +153,49 @@ export function useProtocolRfAutoConnect({ if (isRendererNobleBlePlatform()) { await awaitReticulumBleCoexistenceClear(); } + if (isCancelled()) { + console.debug( + `[useProtocolRfAutoConnect] ${protocol} BLE auto-connect cancelled after coexistence wait`, + ); + notifyPrimaryAutoConnectSettledIfNeeded(protocol); + return; + } if (isNobleBleDualRadioSecondary(protocol)) { await awaitNobleBlePrimaryAutoConnectSettled(POWER_RESUME_MESHCORE_MESHTASTIC_SETTLE_MS); + if (isCancelled()) { + console.debug( + `[useProtocolRfAutoConnect] ${protocol} BLE auto-connect cancelled after primary settle`, + ); + notifyPrimaryAutoConnectSettledIfNeeded(protocol); + return; + } const primary = getNobleBleDualRadioPrimaryProtocol(); if (primary === 'meshtastic' || primary === 'meshcore') { // RfLinkReady unblocks too early — secondary GATT during primary configure drops both. await awaitNobleBleProtocolSettle(primary, POWER_RESUME_MESHCORE_MESHTASTIC_SETTLE_MS); } + if (isCancelled()) { + console.debug( + `[useProtocolRfAutoConnect] ${protocol} BLE auto-connect cancelled after protocol settle`, + ); + notifyPrimaryAutoConnectSettledIfNeeded(protocol); + return; + } + } + + if (isCancelled()) { + console.debug( + `[useProtocolRfAutoConnect] ${protocol} BLE auto-connect cancelled before connect`, + ); + notifyPrimaryAutoConnectSettledIfNeeded(protocol); + return; } await reconnectBleWithScan(protocol, bleId, () => { + if (isCancelled()) { + return Promise.reject(new DOMException('RF auto-connect cancelled', 'AbortError')); + } const attempt = connectAutomaticRef.current('ble', undefined, undefined, bleId); if ( dualNobleBleBothRadiosConfigured() && @@ -169,7 +209,7 @@ export function useProtocolRfAutoConnect({ }; const onSerialAutoConnectFailed = (error: unknown) => { - if (cancelled) return; + if (isCancelled()) return; if (lastBleId && !isLinux) { console.warn( `[useProtocolRfAutoConnect] serial auto-connect failed for ${protocol}; falling back to BLE noble scan: ${errLikeToLogString(error)}`, @@ -189,7 +229,7 @@ export function useProtocolRfAutoConnect({ const runStartupAutoConnect = async (): Promise => { const ready = await waitForProtocolSession(protocol); - if (cancelled) return; + if (isCancelled()) return; if (!ready) { console.warn( `[useProtocolRfAutoConnect] ${protocol} auto-connect skipped — runtime session never registered`, @@ -198,6 +238,11 @@ export function useProtocolRfAutoConnect({ return; } + if (isCancelled()) { + notifyPrimaryAutoConnectSettledIfNeeded(protocol); + return; + } + if (lastConnection.type === 'serial') { startAutoConnectTimeout(); connectAutomaticRef @@ -215,7 +260,7 @@ export function useProtocolRfAutoConnect({ }; runStartupAutoConnect().catch((error: unknown) => { - if (cancelled) return; + if (isCancelled()) return; onAutoConnectFailed(error); notifyPrimaryAutoConnectSettledIfNeeded(protocol); }); diff --git a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts index 1c7152639..d3d5abcfa 100644 --- a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts +++ b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts @@ -54,6 +54,16 @@ describe('shouldDeferMeshcoreTcpReconnectAfterBurst', () => { ).toBe(true); }); + it('defers mid-reconnect FIN that races ahead of burstCaptured', () => { + expect( + shouldDeferMeshcoreTcpReconnectAfterBurst({ + burstCaptured: false, + everConfigured: true, + deviceConfigured: false, + }), + ).toBe(true); + }); + it('does not defer once both everConfigured and deviceConfigured are true', () => { expect( shouldDeferMeshcoreTcpReconnectAfterBurst({ @@ -64,7 +74,7 @@ describe('shouldDeferMeshcoreTcpReconnectAfterBurst', () => { ).toBe(false); }); - it('does not defer before burst capture', () => { + it('does not defer before burst capture on first connect', () => { expect( shouldDeferMeshcoreTcpReconnectAfterBurst({ burstCaptured: false, diff --git a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts index 992e98776..b4b231fc7 100644 --- a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts +++ b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts @@ -16,13 +16,21 @@ export function isMeshcoreTcpBurstDeadBridge(opts: { * Uses !everConfigured so a late tcp-disconnected after a premature deviceConfigured * (Neal: getChannels raced ahead of IPC) cannot abort before connect() latches everConfigured. * Uses !deviceConfigured so mid-reconnect opens (everConfigured already true) still defer. + * Mid-reconnect FIN often races getContacts resolve (burst flag not set yet) — defer whenever + * everConfigured && !deviceConfigured even without burstCaptured. */ export function shouldDeferMeshcoreTcpReconnectAfterBurst(opts: { burstCaptured: boolean; everConfigured: boolean; deviceConfigured: boolean; }): boolean { - return opts.burstCaptured && (!opts.everConfigured || !opts.deviceConfigured); + if (opts.deviceConfigured && opts.everConfigured) { + return false; + } + if (opts.everConfigured && !opts.deviceConfigured) { + return true; + } + return opts.burstCaptured; } type MeshcoreTcpWriteDeadListener = () => void; diff --git a/src/renderer/lib/protocolRfAutoConnectGate.test.ts b/src/renderer/lib/protocolRfAutoConnectGate.test.ts new file mode 100644 index 000000000..d3a3ba788 --- /dev/null +++ b/src/renderer/lib/protocolRfAutoConnectGate.test.ts @@ -0,0 +1,23 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + cancelProtocolRfAutoConnect, + isProtocolRfAutoConnectCancelled, + resetProtocolRfAutoConnectCancel, +} from './protocolRfAutoConnectGate'; + +describe('protocolRfAutoConnectGate', () => { + beforeEach(() => { + resetProtocolRfAutoConnectCancel('meshcore'); + resetProtocolRfAutoConnectCancel('meshtastic'); + }); + + it('tracks cancel per protocol', () => { + expect(isProtocolRfAutoConnectCancelled('meshcore')).toBe(false); + cancelProtocolRfAutoConnect('meshcore'); + expect(isProtocolRfAutoConnectCancelled('meshcore')).toBe(true); + expect(isProtocolRfAutoConnectCancelled('meshtastic')).toBe(false); + resetProtocolRfAutoConnectCancel('meshcore'); + expect(isProtocolRfAutoConnectCancelled('meshcore')).toBe(false); + }); +}); diff --git a/src/renderer/lib/protocolRfAutoConnectGate.ts b/src/renderer/lib/protocolRfAutoConnectGate.ts new file mode 100644 index 000000000..56fa63da0 --- /dev/null +++ b/src/renderer/lib/protocolRfAutoConnectGate.ts @@ -0,0 +1,20 @@ +import type { MeshProtocol } from '@/renderer/lib/types'; + +/** + * Cancels deferred ProtocolAutoConnectCoordinator BLE/serial auto-connect when the user + * starts a manual Connect (or Cancel) on ConnectionPanel. Panel-local autoConnectCancelRef + * is a no-op while suppressMountAutoConnect is set — this gate is the coordinator path. + */ +const cancelledByProtocol = new Map(); + +export function cancelProtocolRfAutoConnect(protocol: MeshProtocol): void { + cancelledByProtocol.set(protocol, true); +} + +export function resetProtocolRfAutoConnectCancel(protocol: MeshProtocol): void { + cancelledByProtocol.set(protocol, false); +} + +export function isProtocolRfAutoConnectCancelled(protocol: MeshProtocol): boolean { + return cancelledByProtocol.get(protocol) === true; +} diff --git a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts index 13ab4d402..7e15a20c1 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts @@ -269,6 +269,12 @@ describe('useMeshcoreRuntime auto-reconnect (regression)', () => { expect(RUNTIME_SOURCE).toContain('captureSerialIdentityForRediscovery'); }); + it('skips Noble BLE disconnect reconnect when connectType is not ble (TCP/serial switch)', () => { + expect(RUNTIME_SOURCE).toMatch( + /onNobleBleDisconnected[\s\S]*?meshcoreConnectTypeRef\.current !== 'ble'[\s\S]*?skip \(connectType=/, + ); + }); + it('notifies immediately on main-process TCP socket disconnect (regression)', () => { // Unlike serial, MeshCore's TCP transport has no fallback watchdog at all (see // startMeshcoreSerialWatchdog, gated on rfType === 'serial'), so meshcore.tcp.onDisconnected @@ -294,7 +300,14 @@ describe('useMeshcoreRuntime auto-reconnect (regression)', () => { expect(RUNTIME_SOURCE).toMatch( /shouldDeferMeshcoreTcpReconnectAfterBurst\(\{[\s\S]*?burstCaptured:[\s\S]*?everConfigured:[\s\S]*?deviceConfigured:[\s\S]*?\}\)[\s\S]*?meshcoreDeferredReconnectRef\.current = true;[\s\S]*?return;[\s\S]*?handleMeshcoreConnectionLostRef\.current\(\)/, ); - expect(RUNTIME_SOURCE).toContain('TCP burst-complete configure — reconnecting dead bridge'); + expect(RUNTIME_SOURCE).toContain('TCP burst-complete configure — accepting dead bridge'); + expect(RUNTIME_SOURCE).toContain( + 'TCP burst-complete reconnect attach — accepting dead bridge (configured)', + ); + expect(RUNTIME_SOURCE).not.toContain('TCP burst-complete configure — reconnecting dead bridge'); + expect(RUNTIME_SOURCE).not.toContain( + 'TCP burst-complete reconnect attach — continue for live socket', + ); expect(RUNTIME_SOURCE).toContain( 'initConn getChannels skipped (TCP burst-complete, bridge dead)', ); @@ -594,4 +607,32 @@ describe('useMeshcoreRuntime prepareRfConnect driver teardown (regression)', () expect(prepareBody).toContain('meshcoreReconnectAttemptRef.current = 0'); expect(prepareBody).toContain('meshcoreIsReconnectingRef.current = false'); }); + + it('always bumps setupGeneration and disconnects TCP bridge (BLE vs TCP race)', () => { + const prepareBody = extractUseCallbackBody(RUNTIME_SOURCE, 'prepareRfConnect'); + expect(prepareBody).toMatch(/meshcoreSetupGenerationRef\.current \+= 1/); + expect(prepareBody).toContain('window.electronAPI.meshcore.tcp.disconnect()'); + expect(prepareBody).toContain('meshcorePendingDriverIdentityRef.current'); + }); + + it('clears connection params on manual prepare and latches provisional params before open', () => { + const prepareBody = extractUseCallbackBody(RUNTIME_SOURCE, 'prepareRfConnect'); + expect(prepareBody).toMatch( + /if \(!opts\?\.preserveReconnectState\) \{\s*meshcoreConnectionParamsRef\.current = null;/, + ); + const connectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'connect'); + expect(connectBody).toMatch( + /await prepareRfConnect\(type\);[\s\S]*?meshcoreConnectionParamsRef\.current = \{[\s\S]*?rfType: type,/, + ); + }); + + it('latches pending driver identity after open before attach (mid-open supersede)', () => { + const connectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'connect'); + expect(connectBody).toMatch( + /meshcorePendingDriverIdentityRef\.current = opened\.driverIdentityId/, + ); + expect(connectBody).toMatch( + /meshcoreSetupGenerationRef\.current !== connectSetupGen[\s\S]*?MESHCORE_SETUP_ABORT_MESSAGE/, + ); + }); }); diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 554365c25..226993c9a 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -1911,6 +1911,15 @@ export function useMeshcoreRuntime() { useEffect(() => { return window.electronAPI.onNobleBleDisconnected((sessionId) => { if (sessionId !== 'meshcore') return; + // prepareRfConnect(tcp|serial) disconnects Noble as intentional teardown. That async + // disconnect must not call handleMeshcoreConnectionLost — it bumps setupGeneration and + // aborts the in-flight TCP/serial connect (Mac: BLE auto then manual TCP). + if (meshcoreConnectTypeRef.current !== 'ble') { + console.debug( + `[useMeshcoreRuntime] Noble BLE disconnected — skip (connectType=${meshcoreConnectTypeRef.current ?? 'null'})`, + ); + return; + } // Defer only before active configure (not meshcoreEverConfiguredRef). Once initConn has // marked the session configured, Noble drops must reach handleMeshcoreConnectionLost even // if bleConnectInProgress / reconnect open is still true for remaining init work. @@ -2707,9 +2716,12 @@ export function useMeshcoreRuntime() { type: 'ble' | 'serial' | 'tcp', opts?: { preserveReconnectState?: boolean }, ): Promise => { + // Always bump: abort in-flight initConn/attach when another connect supersedes + // (BLE auto-connect vs manual TCP race — openMeshCoreTransport can leave a live + // meshcore:tcp socket before attachRfSession sets driverConnected). + meshcoreSetupGenerationRef.current += 1; if (type === 'ble' && bleConnectInProgressRef.current) { console.debug('[useMeshcoreRuntime] prepareRfConnect BLE superseding in-flight connect'); - meshcoreSetupGenerationRef.current += 1; bleConnectInProgressRef.current = false; meshcoreDeferredReconnectRef.current = false; } @@ -2719,9 +2731,11 @@ export function useMeshcoreRuntime() { type === 'ble', resolveLastBlePeripheralId('meshcore') ?? null, ); - const driverIdentity = meshcoreDriverConnectedRef.current - ? (meshcoreIdentityIdRef.current ?? meshcorePendingDriverIdentityRef.current) - : null; + // Prefer pending (set right after openMeshCoreTransport) so a mid-open supersede can + // disconnect the driver before attachRfSession latches driverConnected. + const driverIdentity = + meshcorePendingDriverIdentityRef.current ?? + (meshcoreDriverConnectedRef.current ? meshcoreIdentityIdRef.current : null); const staleConn = connRef.current; connRef.current = null; if (driverIdentity) { @@ -2742,7 +2756,20 @@ export function useMeshcoreRuntime() { console.debug('[useMeshcoreRuntime] prepareRfConnect close ' + errLikeToLogString(e)); }); } + // Always clear the main-process TCP bridge. openMeshCoreTransport can leave + // meshcoreTcpSocket live while driverConnected/connRef are still unset — a racing + // BLE prepare used to orphan that socket and let TCP init continue with connectType=ble. + await window.electronAPI.meshcore.tcp.disconnect().catch((e: unknown) => { + console.debug( + '[useMeshcoreRuntime] prepareRfConnect tcp.disconnect ' + errLikeToLogString(e), + ); + }); meshcoreConnectTypeRef.current = type; + // Manual / new connect: drop prior-session params so mid-open loss cannot rehydrate + // stale BLE and prepareRfConnect(ble) while TCP is still opening (Mac race after race-fix). + if (!opts?.preserveReconnectState) { + meshcoreConnectionParamsRef.current = null; + } if (type === 'tcp') { meshcoreTcpBridgeDeadRef.current = false; meshcoreTcpInitBurstCapturedRef.current = false; @@ -3146,6 +3173,7 @@ export function useMeshcoreRuntime() { await lateTransport.cleanup(opened.driverIdentityId); throw new Error('MeshCore reconnect superseded after open'); } + meshcorePendingDriverIdentityRef.current = opened.driverIdentityId; await attachRfSession(opened.driverIdentityId, params.rfType); if (meshcoreReconnectGenerationRef.current !== generation || !attemptActive) { await lateTransport.cleanup(opened.driverIdentityId); @@ -3177,21 +3205,14 @@ export function useMeshcoreRuntime() { clearMeshcoreBleMacSuppression(); } // Burst-complete attach left a dead bridge (OpenHop FIN after contacts). UI is configured - // from held contacts — keep the reconnect budget and retry for a live socket instead of - // markSuccess (which would reset attempts and loop forever). + // from the contacts burst — accept that session. Forcing an immediate live-socket retry + // loops forever on companions that FIN after every contacts dump (WAN :5054 / SoftAP). + // Live recovery happens on write-fail / later tcp-disconnected once deviceConfigured. if (params.rfType === 'tcp' && meshcoreDeferredReconnectRef.current) { meshcoreDeferredReconnectRef.current = false; - meshcoreIsReconnectingRef.current = true; - setState((s) => ({ - ...s, - serialNeedsReselect: false, - connectionLoss: false, - })); console.debug( - '[useMeshcoreRuntime] TCP burst-complete reconnect attach — continue for live socket', + '[useMeshcoreRuntime] TCP burst-complete reconnect attach — accepting dead bridge (configured)', ); - scheduleMeshcoreReconnectAttemptRef.current(); - return; } meshcoreReconnectAttemptRef.current = 0; meshcoreIsReconnectingRef.current = false; @@ -3456,6 +3477,16 @@ export function useMeshcoreRuntime() { type === 'ble' && navigator.userAgent.toLowerCase().includes('linux'); await prepareRfConnect(type); + const connectSetupGen = meshcoreSetupGenerationRef.current; + // Provisional reconnect target for the intended transport before open/attach completes. + // Without this, a peer FIN mid-open used lastConnection BLE and tore down TCP. + meshcoreConnectionParamsRef.current = { + rfType: type, + httpAddress: type === 'tcp' ? tcpHost : undefined, + blePeripheralId: type === 'ble' ? blePeripheralId : undefined, + serialPortId: type === 'serial' ? localStorage.getItem(LAST_SERIAL_PORT_KEY) : undefined, + serialPort: null, + }; let opened: Awaited> | undefined; let connectSucceeded = false; @@ -3472,6 +3503,25 @@ export function useMeshcoreRuntime() { type === 'ble' && isRendererNobleBlePlatform() ? await withNobleBleConnectMutex('meshcore', openTransport) : await openTransport(); + // Latch pending before attach so a racing prepareRfConnect can driver-disconnect + // this open (attachRfSession previously left a gap where TCP stayed orphaned). + meshcorePendingDriverIdentityRef.current = opened.driverIdentityId; + if (meshcoreSetupGenerationRef.current !== connectSetupGen) { + meshcorePendingDriverIdentityRef.current = null; + await connectionDriver.disconnect(opened.driverIdentityId).catch((e: unknown) => { + console.debug( + '[useMeshcoreRuntime] connect superseded disconnect ' + errLikeToLogString(e), + ); + }); + if (type === 'tcp') { + await window.electronAPI.meshcore.tcp.disconnect().catch((e: unknown) => { + console.debug( + '[useMeshcoreRuntime] connect superseded tcp.disconnect ' + errLikeToLogString(e), + ); + }); + } + throw new DOMException(MESHCORE_SETUP_ABORT_MESSAGE, 'AbortError'); + } await attachRfSession(opened.driverIdentityId, type); const bleIdentityOpts = type === 'ble' @@ -3501,16 +3551,14 @@ export function useMeshcoreRuntime() { connectSucceeded = true; meshcoreEverConfiguredRef.current = true; // Neal OpenHop: peer FIN after contacts — initConn completed configured from the burst - // with a dead bridge; reconnect now that everConfigured is latched. + // with a dead bridge. Do not force an immediate live-socket reconnect (companions that + // FIN after every contacts dump would loop forever). Write-fail / later IPC close + // reconnect once deviceConfigured is latched. if (type === 'tcp' && meshcoreDeferredReconnectRef.current) { meshcoreDeferredReconnectRef.current = false; - queueMicrotask(() => { - if (meshcoreExplicitDisconnectRef.current) return; - console.debug( - '[useMeshcoreRuntime] TCP burst-complete configure — reconnecting dead bridge', - ); - handleMeshcoreConnectionLostRef.current(); - }); + console.debug( + '[useMeshcoreRuntime] TCP burst-complete configure — accepting dead bridge', + ); } } catch (err) { const isSetupAbort = isMeshcoreSetupAbortError(err); @@ -7284,13 +7332,12 @@ export function useMeshcoreRuntime() { meshcoreTcpBridgeDeadRef.current = true; // Defer while this open can still finish from the contacts burst (!everConfigured covers // late IPC after premature deviceConfigured; !deviceConfigured covers mid-reconnect opens). - if ( - shouldDeferMeshcoreTcpReconnectAfterBurst({ - burstCaptured: meshcoreTcpInitBurstCapturedRef.current, - everConfigured: meshcoreEverConfiguredRef.current, - deviceConfigured: meshcoreDeviceConfiguredRef.current, - }) - ) { + const defer = shouldDeferMeshcoreTcpReconnectAfterBurst({ + burstCaptured: meshcoreTcpInitBurstCapturedRef.current, + everConfigured: meshcoreEverConfiguredRef.current, + deviceConfigured: meshcoreDeviceConfiguredRef.current, + }); + if (defer) { meshcoreDeferredReconnectRef.current = true; console.debug( source === 'write' From d97c177b2f6331ebc36e0d7daf3fb573bfd30bdb Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 05:59:57 -0600 Subject: [PATCH 3/7] fix(meshcore): configure after contacts dump without SoftAP reconnect storms Latch session readiness after self-info for all RF transports, but keep UI connected until getContacts settles so flood-advert/stats/GPS writes do not interleave with the dump. SoftAP dump FINs stay configured without clobbering dbCache hydration. --- ...eshcoreRuntime.initconn-rpc-order.test.tsx | 21 +- .../lib/meshcore/meshcoreTcpInitBurst.test.ts | 11 + .../lib/meshcore/meshcoreTcpInitBurst.ts | 8 +- .../loraRfReconnectParity.contract.test.ts | 14 +- .../useMeshcoreRuntime.reconnect.test.ts | 23 +- src/renderer/runtime/useMeshcoreRuntime.ts | 1187 +++++++++-------- 6 files changed, 686 insertions(+), 578 deletions(-) diff --git a/src/renderer/hooks/useMeshcoreRuntime.initconn-rpc-order.test.tsx b/src/renderer/hooks/useMeshcoreRuntime.initconn-rpc-order.test.tsx index 8441d993e..f8ab9d502 100644 --- a/src/renderer/hooks/useMeshcoreRuntime.initconn-rpc-order.test.tsx +++ b/src/renderer/hooks/useMeshcoreRuntime.initconn-rpc-order.test.tsx @@ -324,7 +324,7 @@ describe('useMeshcoreRuntime initConn RPC ordering', () => { unmount(); }); - it('tcp: stays connected (not configured) until after getChannels so room auto-login cannot overlap contact dump', async () => { + it('tcp: latches session after self-info; UI configured after contacts dump; channels follow contacts', async () => { const callOrder: string[] = []; const gates = installSequentialInitGates(callOrder); @@ -342,18 +342,17 @@ describe('useMeshcoreRuntime initConn RPC ordering', () => { gates.selfInfoGate.resolve(undefined); await waitFor(() => { expect(callOrder).toContain('getSelfInfo:end'); + expect(result.current.state.status).toBe('connected'); expect(callOrder).toContain('getContacts:start'); }); - // After selfInfo, TCP must not look fully configured yet (room auto-login gates on configured). - expect(result.current.state.status).toBe('connected'); expect(callOrder).not.toContain('getChannels:start'); gates.contactsGate.resolve(undefined); await waitFor(() => { expect(callOrder).toContain('getContacts:end'); expect(callOrder).toContain('getChannels:start'); + expect(result.current.state.status).toBe('configured'); }); - expect(result.current.state.status).toBe('connected'); gates.channelsGate.resolve(undefined); await waitFor(() => { @@ -371,7 +370,7 @@ describe('useMeshcoreRuntime initConn RPC ordering', () => { unmount(); }); - it('tcp: peer disconnect after getContacts completes configured from captured burst', async () => { + it('tcp: peer disconnect after getContacts keeps configured from post-configure dump', async () => { const callOrder: string[] = []; const gates = installSequentialInitGates(callOrder); const discCallbacks: (() => void)[] = []; @@ -401,6 +400,7 @@ describe('useMeshcoreRuntime initConn RPC ordering', () => { await waitFor(() => { expect(callOrder).toContain('getContacts:end'); }); + expect(result.current.state.status).toBe('configured'); await act(async () => { for (const cb of discCallbacks) cb(); await Promise.resolve(); @@ -461,7 +461,7 @@ describe('useMeshcoreRuntime initConn RPC ordering', () => { unmount(); }); - it('tcp: peer FIN before getContacts completes aborts init without getChannels', async () => { + it('tcp: peer FIN during getContacts keeps configured (post-configure dump)', async () => { const callOrder: string[] = []; const gates = installSequentialInitGates(callOrder); const discCallbacks: (() => void)[] = []; @@ -481,6 +481,9 @@ describe('useMeshcoreRuntime initConn RPC ordering', () => { expect(callOrder).toContain('getSelfInfo:start'); }); gates.selfInfoGate.resolve(undefined); + await waitFor(() => { + expect(result.current.state.status).toBe('connected'); + }); await waitFor(() => { expect(callOrder).toContain('getContacts:start'); }); @@ -490,15 +493,15 @@ describe('useMeshcoreRuntime initConn RPC ordering', () => { for (const cb of discCallbacks) cb(); await Promise.resolve(); }); - // Unblock getContacts so initConn can observe the dead bridge and abort. + // Unblock getContacts so soft-fail / empty-contacts path can finish. gates.contactsGate.resolve(undefined); await act(async () => { - await expect(connectPromise).rejects.toBeTruthy(); + await connectPromise; }); expect(callOrder).not.toContain('getChannels:start'); - expect(result.current.state.status).not.toBe('configured'); + expect(result.current.state.status).toBe('configured'); unmount(); }); diff --git a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts index d3d5abcfa..624190985 100644 --- a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts +++ b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts @@ -64,6 +64,17 @@ describe('shouldDeferMeshcoreTcpReconnectAfterBurst', () => { ).toBe(true); }); + it('defers while initConn is still finishing after configure-before-dump', () => { + expect( + shouldDeferMeshcoreTcpReconnectAfterBurst({ + burstCaptured: true, + everConfigured: true, + deviceConfigured: true, + initConnInFlight: true, + }), + ).toBe(true); + }); + it('does not defer once both everConfigured and deviceConfigured are true', () => { expect( shouldDeferMeshcoreTcpReconnectAfterBurst({ diff --git a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts index b4b231fc7..36ce9ad41 100644 --- a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts +++ b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts @@ -12,18 +12,24 @@ export function isMeshcoreTcpBurstDeadBridge(opts: { } /** - * Defer reconnect while this open can still finish configured from the contacts burst. + * Defer reconnect while this open can still finish from the contacts burst / remaining init. * Uses !everConfigured so a late tcp-disconnected after a premature deviceConfigured * (Neal: getChannels raced ahead of IPC) cannot abort before connect() latches everConfigured. * Uses !deviceConfigured so mid-reconnect opens (everConfigured already true) still defer. * Mid-reconnect FIN often races getContacts resolve (burst flag not set yet) — defer whenever * everConfigured && !deviceConfigured even without burstCaptured. + * Configure-before-dump: deviceConfigured+everConfigured are both true during getChannels — + * still defer while initConn is in flight after the burst so peer FIN does not bump setup gen. */ export function shouldDeferMeshcoreTcpReconnectAfterBurst(opts: { burstCaptured: boolean; everConfigured: boolean; deviceConfigured: boolean; + initConnInFlight?: boolean; }): boolean { + if (opts.initConnInFlight && opts.burstCaptured) { + return true; + } if (opts.deviceConfigured && opts.everConfigured) { return false; } diff --git a/src/renderer/runtime/loraRfReconnectParity.contract.test.ts b/src/renderer/runtime/loraRfReconnectParity.contract.test.ts index dcdad6b55..4b85680cd 100644 --- a/src/renderer/runtime/loraRfReconnectParity.contract.test.ts +++ b/src/renderer/runtime/loraRfReconnectParity.contract.test.ts @@ -52,13 +52,15 @@ describe('LoRa RF reconnect parity (MeshCore ↔ Meshtastic)', () => { }, ); - it('MeshCore TCP defers status=configured until after contacts+channels', () => { - expect(MESHCORE).toContain("const deferConfiguredUntilRadioInit = transportType === 'tcp'"); + it('MeshCore latches session after self-info; UI configured after contacts dump on all RF transports', () => { + expect(MESHCORE).toContain('const configureBeforeContactsDump = true'); + expect(MESHCORE).toContain('meshcoreTcpContactsDumpInFlightRef'); + expect(MESHCORE).toContain('TCP closed during post-configure contacts dump — keep configured'); + expect(MESHCORE).toContain('preserving dbCache hydration'); + expect(MESHCORE).toContain('promoteConfiguredAfterContactsDump'); + expect(MESHCORE).toContain('keep UI status at'); expect(MESHCORE).toMatch( - /deferConfiguredUntilRadioInit \? 'connected' : 'configured'[\s\S]*?if \(!deferConfiguredUntilRadioInit\) \{[\s\S]*?meshcoreDeviceConfiguredRef\.current = true/, - ); - expect(MESHCORE).toMatch( - /if \(deferConfiguredUntilRadioInit\) \{[\s\S]*?status: 'configured'[\s\S]*?triggerRoomAutoLoginRef\.current\(\)/, + /meshcoreDeviceConfiguredRef\.current = true[\s\S]*?getContacts[\s\S]*?promoteConfiguredAfterContactsDump/, ); }); diff --git a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts index 7e15a20c1..ed07792c7 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts @@ -50,7 +50,9 @@ describe('useMeshcoreRuntime auto-reconnect (regression)', () => { expect(RUNTIME_SOURCE).toContain( 'initConn TCP burst-complete with dead bridge — skip post-connect RPCs', ); - expect(RUNTIME_SOURCE).toMatch(/meshcoreEverConfiguredRef\.current = true;\s*\n\s*\},/); + expect(RUNTIME_SOURCE).toMatch( + /meshcoreEverConfiguredRef\.current = true;\s*\n\s*\} finally \{\s*\n\s*meshcoreInitConnInFlightRef\.current = false;/, + ); }); it('prepareRfConnect preserves reconnect state when requested', () => { @@ -494,20 +496,19 @@ describe('useMeshcoreRuntime manual disconnect must not auto-reconnect', () => { expect(lostBody.slice(asyncIdx)).not.toContain('meshcoreSetupGenerationRef.current += 1'); }); - it('hard-aborts TCP initConn on dead socket before configured / post-connect', () => { + it('latches session before contacts dump and soft-fails without clobbering hydration', () => { + expect(RUNTIME_SOURCE).toContain('configureBeforeContactsDump'); + expect(RUNTIME_SOURCE).toContain('promoteConfiguredAfterContactsDump'); + expect(RUNTIME_SOURCE).toContain('meshcoreTcpContactsDumpInFlightRef'); expect(RUNTIME_SOURCE).toContain('assertInitConnStillLive'); expect(RUNTIME_SOURCE).toContain('rethrowMeshcoreSetupAbortFromTcpDead'); expect(RUNTIME_SOURCE).toContain('isMeshcoreTcpTransportDeadError'); - const deferConfiguredIdx = RUNTIME_SOURCE.search( - /if\s*\(\s*deferConfiguredUntilRadioInit\s*\)\s*\{\s*setState\s*\(\s*\(prev\)\s*=>\s*\(\s*\{\s*\.\.\.prev\s*,\s*status:\s*'configured'/, - ); - expect(deferConfiguredIdx).toBeGreaterThan(-1); - const assertBeforeConfigured = RUNTIME_SOURCE.lastIndexOf( - 'assertInitConnStillLive()', - deferConfiguredIdx, + expect(RUNTIME_SOURCE).toContain('getContacts failed after configured — keeping session'); + expect(RUNTIME_SOURCE).toContain( + 'TCP closed during post-configure contacts dump — keep configured', ); - expect(assertBeforeConfigured).toBeGreaterThan(-1); - expect(assertBeforeConfigured).toBeLessThan(deferConfiguredIdx); + expect(RUNTIME_SOURCE).toContain('contactsDumpOk'); + expect(RUNTIME_SOURCE).toContain('contacts dump soft-failed — preserving dbCache hydration'); }); it('coalesces reconnect attempt schedules via scheduleOwner', () => { diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 226993c9a..cbaa9d1c0 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -611,6 +611,16 @@ export function useMeshcoreRuntime() { * reconnect instead of aborting into a never-configured loop (Neal). */ const meshcoreTcpInitBurstCapturedRef = useRef(false); + /** + * TCP: true while post-configure getContacts is in flight. Peer FIN during the dump must + * latch bridge-dead without handleMeshcoreConnectionLost (session already configured). + */ + const meshcoreTcpContactsDumpInFlightRef = useRef(false); + /** + * True for the duration of `initConn`. After configure-before-dump, peer FIN once the contacts + * burst is held must defer reconnect (not bump setup gen) until init finishes. + */ + const meshcoreInitConnInFlightRef = useRef(false); /** * Active session configured (Meshtastic `deviceConfiguredRef` parity). Cleared on disconnect / * new connect; set when initConn reaches configured so post-configure Noble drops during @@ -950,12 +960,13 @@ export function useMeshcoreRuntime() { myNodeNumRef.current = state.myNodeNum; }, [state.myNodeNum]); - // Start stats polling when connected + // Start stats polling when configured (after contacts dump — not during initConn). useEffect(() => { if (state.status === 'configured') { if (meshcoreStatsPollRef.current) clearInterval(meshcoreStatsPollRef.current); meshcoreStatsPollRef.current = setInterval(() => { if (!meshcoreHookMountedRef.current) return; + if (meshcoreInitConnInFlightRef.current) return; void fetchAndUpdateLocalStats().catch((e: unknown) => { console.warn('[useMeshcoreRuntime] periodic stats poll failed ' + errLikeToLogString(e)); }); @@ -2056,616 +2067,643 @@ export function useMeshcoreRuntime() { /** Shared post-connection handshake: wire events, fetch self info, contacts, channels. */ const initConn = useCallback( async (conn: MeshCoreConnection, setupGen: number, opts?: { driverIdentityId?: string }) => { - connRef.current = conn; - meshcoreConnEventListenersTeardownRef.current?.(); - meshcoreConnEventListenersTeardownRef.current = setupEventListeners(conn); + meshcoreInitConnInFlightRef.current = true; + try { + connRef.current = conn; + meshcoreConnEventListenersTeardownRef.current?.(); + meshcoreConnEventListenersTeardownRef.current = setupEventListeners(conn); - // meshcore.js runs deviceQuery(SupportedCompanionProtocolVersion) from onConnected() on the next - // macrotask; register before any await so we capture that DeviceInfo (manufacturer string, build date). - conn.once(MESHCORE_RESPONSE_DEVICE_INFO, (response: unknown) => { - setState((prev) => { - const next = { ...prev }; - const r = response as { firmware_build_date?: string }; - if (typeof r?.firmware_build_date === 'string' && r.firmware_build_date.trim()) { - next.firmwareVersion = r.firmware_build_date.trim(); - } - const mm = meshcoreManufacturerModelFromDeviceQuery(response); - if (mm) next.manufacturerModel = mm; - return next; + // meshcore.js runs deviceQuery(SupportedCompanionProtocolVersion) from onConnected() on the next + // macrotask; register before any await so we capture that DeviceInfo (manufacturer string, build date). + conn.once(MESHCORE_RESPONSE_DEVICE_INFO, (response: unknown) => { + setState((prev) => { + const next = { ...prev }; + const r = response as { firmware_build_date?: string }; + if (typeof r?.firmware_build_date === 'string' && r.firmware_build_date.trim()) { + next.firmwareVersion = r.firmware_build_date.trim(); + } + const mm = meshcoreManufacturerModelFromDeviceQuery(response); + if (mm) next.manufacturerModel = mm; + return next; + }); }); - }); - // Load persisted messages in background (not required for contact/repeater list). - void (async () => { - try { - const dbMsgs = await awaitUnlessMeshcoreSetupCancelled( - setupGen, - loadMeshcoreMessagesForHydration(), - ); - if (dbMsgs.length > 0) { - const contactRows = - (await window.electronAPI.db.getMeshcoreContacts()) as MeshcoreContactDbRow[]; - const mapped = repairMeshcoreHydratedMessages( - mapMeshcoreDbRowsToChatMessages(dbMsgs), - meshcoreRoomServerIdsFromContacts(contactRows), - myNodeNumRef.current, - ); - setNodes((prev) => mergeStubNodesFromMeshcoreMessages(prev, mapped)); - setMessages((prev) => mergeMeshcoreDbHydrationWithLive(prev, mapped)); - } - } catch (e) { - if (isMeshcoreSetupAbortError(e)) return; - console.warn('[useMeshcoreRuntime] loadMessagesFromDb error ' + errLikeToLogString(e)); - } - })(); - - const initConnPerfStart = performance.now(); - const driverStoreId = opts?.driverIdentityId ?? resolveMeshcoreStoreIdentityId(); - if (driverStoreId) { - meshcoreIdentityIdRef.current = driverStoreId; - setMeshcoreIdentityId(driverStoreId); - } - - const sequentialRadioInit = needsSequentialMeshcoreRadioInit(meshcoreConnectTypeRef.current); - const getSelfInfoStart = performance.now(); - let getContactsStart = getSelfInfoStart; - let parallelSelfInfoPromise: ReturnType | undefined; - let parallelContactsPromise: Promise | undefined; - if (!sequentialRadioInit) { - parallelSelfInfoPromise = awaitUnlessMeshcoreSetupCancelled( - setupGen, - conn.getSelfInfo(5000), - ); - observeMeshcoreSetupAbort(parallelSelfInfoPromise); - getContactsStart = performance.now(); - parallelContactsPromise = awaitUnlessMeshcoreSetupCancelled( - setupGen, - (async () => { - if (meshcoreConnectTypeRef.current === 'ble') { - await awaitDualNobleBleMeshtasticSettle(); - } - return withTimeout(conn.getContacts(), MESHCORE_INIT_TIMEOUT_MS, 'getContacts'); - })(), - ); - observeMeshcoreSetupAbort(parallelContactsPromise); - const channelsPromise = awaitUnlessMeshcoreSetupCancelled( - setupGen, - withTimeout(conn.getChannels(), MESHCORE_INIT_TIMEOUT_MS, 'getChannels'), - ); + // Load persisted messages in background (not required for contact/repeater list). void (async () => { try { - const rawChannels = await channelsPromise; - setChannels( - dedupeChannelPillsByIndex( - rawChannels.map((c) => ({ index: c.channelIdx, name: c.name, secret: c.secret })), - ), + const dbMsgs = await awaitUnlessMeshcoreSetupCancelled( + setupGen, + loadMeshcoreMessagesForHydration(), ); + if (dbMsgs.length > 0) { + const contactRows = + (await window.electronAPI.db.getMeshcoreContacts()) as MeshcoreContactDbRow[]; + const mapped = repairMeshcoreHydratedMessages( + mapMeshcoreDbRowsToChatMessages(dbMsgs), + meshcoreRoomServerIdsFromContacts(contactRows), + myNodeNumRef.current, + ); + setNodes((prev) => mergeStubNodesFromMeshcoreMessages(prev, mapped)); + setMessages((prev) => mergeMeshcoreDbHydrationWithLive(prev, mapped)); + } } catch (e) { if (isMeshcoreSetupAbortError(e)) return; - console.warn('[useMeshcoreRuntime] getChannels error ' + errLikeToLogString(e)); + console.warn('[useMeshcoreRuntime] loadMessagesFromDb error ' + errLikeToLogString(e)); } })(); - } - // Show persisted contacts immediately while the radio contact dump runs over BLE. - const dbCacheStart = performance.now(); - let dbCacheNodeCount = 0; - if (driverStoreId) { - try { - const [rows, dbMsgs, savedNodes] = await Promise.all([ - window.electronAPI.db.getMeshcoreContacts(), - loadMeshcoreMessagesForHydration(), - window.electronAPI.db.getNodes(), - ]); - const contactRows = rows as MeshcoreContactDbRow[]; - registerMeshcorePubKeysFromContactDbRows(contactRows); - copyMeshcorePubKeyRegistryToRefs(pubKeyMapRef.current, pubKeyPrefixMapRef.current); - const mapped = repairMeshcoreHydratedMessages( - mapMeshcoreDbRowsToChatMessages(dbMsgs), - meshcoreRoomServerIdsFromContacts(contactRows), - myNodeNumRef.current, - ); - const cachedNodes = buildMeshcoreNodeMapFromDb(contactRows, savedNodes, mapped); - dbCacheNodeCount = cachedNodes.size; - meshcoreLastPersistedNodesRef.current = new Map(cachedNodes); - applyMeshcoreNodesToUi(cachedNodes); - } catch (e) { - if (isMeshcoreSetupAbortError(e)) throw e; - console.warn( - '[useMeshcoreRuntime] initConn db cache hydrate failed ' + errLikeToLogString(e), - ); + const initConnPerfStart = performance.now(); + const driverStoreId = opts?.driverIdentityId ?? resolveMeshcoreStoreIdentityId(); + if (driverStoreId) { + meshcoreIdentityIdRef.current = driverStoreId; + setMeshcoreIdentityId(driverStoreId); } - } - const dbCacheMs = Math.round(performance.now() - dbCacheStart); - console.debug( - `[useMeshcoreRuntime] initConn dbCache→UI ${dbCacheMs}ms (${dbCacheNodeCount} nodes)`, - ); - - // TCP: ConnectionDriver.discoverSelf already ran getSelfInfo — reuse to avoid a second - // companion RPC that SoftAP/OpenHop often FINs after (Neal/Fuzzy). - const reusedDiscoverSelf = - sequentialRadioInit && meshcoreConnectTypeRef.current === 'tcp' - ? takeMeshcoreDiscoverSelfCache(conn) - : undefined; - const rawInfo = - reusedDiscoverSelf ?? - (sequentialRadioInit - ? await awaitUnlessMeshcoreSetupCancelled(setupGen, conn.getSelfInfo(5000)) - : await parallelSelfInfoPromise!); - const getSelfInfoMs = Math.round(performance.now() - getSelfInfoStart); - console.debug( - reusedDiscoverSelf - ? `[useMeshcoreRuntime] initConn getSelfInfo ${getSelfInfoMs}ms (reused discoverSelf)` - : `[useMeshcoreRuntime] initConn getSelfInfo ${getSelfInfoMs}ms`, - ); - const info = enrichMeshCoreSelfInfo(rawInfo); - setSelfInfo(info); - setState((prev) => ({ ...prev, status: 'connected' })); - - const myNodeId = pubkeyToNodeId(info.publicKey); - persistMeshcoreSelfNodeId(myNodeId); - tryPersistMeshcorePublicKeyFromRadio(info.publicKey); - const transportType = meshcoreConnectTypeRef.current; - // TCP: defer status=configured until after contacts+channels so room auto-login cannot - // overlap the companion contact dump (n7eal TCP mid-initConn drops / #792 follow-up). - const deferConfiguredUntilRadioInit = transportType === 'tcp'; - setState((prev) => ({ - ...prev, - myNodeNum: myNodeId, - status: deferConfiguredUntilRadioInit ? 'connected' : 'configured', - connectionLoss: false, - serialNeedsReselect: false, - })); - if (!deferConfiguredUntilRadioInit) { - meshcoreDeviceConfiguredRef.current = true; - } - if (getStoredMeshProtocol() === 'meshcore') { - useDiagnosticsStore.getState().migrateForeignLoraFromZero(myNodeId); - } - const discovery = { myNodeNum: myNodeId, publicKey: info.publicKey }; - let identityId = opts?.driverIdentityId ?? null; - if (identityId) { - if (meshcoreIngressDetachRef.current) { - meshcoreIngressDetachRef.current(); - meshcoreIngressDetachRef.current = null; - } - finalizeMeshcoreDriverIdentity( - identityId, - meshcoreTransportParams(transportType, {}), - discovery, - ); - meshcoreIdentityIdRef.current = identityId; - setMeshcoreIdentityId(identityId); - } else { - if (meshcoreIngressDetachRef.current) { - meshcoreIngressDetachRef.current(); - } - // MeshCore protocol ingress owns every inbound push; companion RPCs - // (stats, repeater admin) remain hook-owned request/response paths. - const ingress = attachMeshcoreProtocolIngress( - conn as unknown as Connection, - transportType, - {}, - discovery, + const sequentialRadioInit = needsSequentialMeshcoreRadioInit( + meshcoreConnectTypeRef.current, ); - meshcoreIngressDetachRef.current = ingress.detach; - identityId = ingress.identityId; - meshcoreIdentityIdRef.current = identityId; - setMeshcoreIdentityId(identityId); - } - if (meshcoreIngestDetachRef.current) { - meshcoreIngestDetachRef.current(); - } - if (identityId) { - meshcoreIngestDetachRef.current = attachMeshcoreIngest(identityId, { - onPathUpdated: handleMeshcorePathUpdatedFromIngest, - rawPacketsForHopCorrelation: () => rawPacketsRef.current, - }); - setConnection(identityId, { - status: deferConfiguredUntilRadioInit ? 'connected' : 'configured', - connectionType: transportType === 'tcp' ? 'http' : transportType, - myNodeNum: myNodeId, - }); - } - - // TCP/peer FIN during contact dump: abort before burst is captured. After burst, OpenHop - // clean-FIN is tolerated so we can latch configured from held contacts (Neal). - const assertInitConnStillLive = (): void => { - if (meshcoreSetupGenerationRef.current !== setupGen) { - throw new DOMException(MESHCORE_SETUP_ABORT_MESSAGE, 'AbortError'); - } - const tcpBurstOk = transportType === 'tcp' && meshcoreTcpInitBurstCapturedRef.current; - if (tcpBurstOk) return; - if (transportType === 'tcp' && meshcoreTcpBridgeDeadRef.current) { - throw new DOMException(MESHCORE_SETUP_ABORT_MESSAGE, 'AbortError'); - } - if (transportType === 'tcp' && connRef.current !== conn) { - throw new DOMException(MESHCORE_SETUP_ABORT_MESSAGE, 'AbortError'); - } - }; - - if (sequentialRadioInit) { - getContactsStart = performance.now(); - } - const contactsRaw = sequentialRadioInit - ? await awaitUnlessMeshcoreSetupCancelled( + const getSelfInfoStart = performance.now(); + let getContactsStart = getSelfInfoStart; + let parallelSelfInfoPromise: ReturnType | undefined; + let parallelContactsPromise: Promise | undefined; + if (!sequentialRadioInit) { + parallelSelfInfoPromise = awaitUnlessMeshcoreSetupCancelled( setupGen, - withTimeout(conn.getContacts(), MESHCORE_INIT_TIMEOUT_MS, 'getContacts'), - ) - : await parallelContactsPromise!; - const getContactsMs = Math.round(performance.now() - getContactsStart); - console.debug( - `[useMeshcoreRuntime] initConn getContacts ${getContactsMs}ms (total ${Math.round(performance.now() - initConnPerfStart)}ms)`, - ); - // Contact payload held — TCP peer FIN from here on completes configured from this burst. - if (transportType === 'tcp') { - meshcoreTcpInitBurstCapturedRef.current = true; - } - // Fuzzy SoftAP: peer FIN often lands between getContacts resolve and contacts→UI — - // abort before DB/UI work when burst was not yet captured (pre-TCP path above). - assertInitConnStillLive(); - // Reconcile radio truth: clear stale flags before re-marking contacts seen on-device. - try { - await window.electronAPI.db.markAllMeshcoreContactsOffRadio(); - } catch (e) { - console.warn( - '[useMeshcoreRuntime] initConn markAllMeshcoreContactsOffRadio failed ' + - errLikeToLogString(e), - ); - } - assertInitConnStillLive(); - const contacts = contactsRaw.map(meshcoreContactRawFromDevice); - setMeshcoreContactsForTelemetry(contacts); - const previousNodesBaseline = meshcorePreviousNodesBaselineForBuild(); - const newNodes = await awaitUnlessMeshcoreSetupCancelled( - setupGen, - buildNodesFromContacts(contacts, { - self: info, - myNodeId, - previousNodes: previousNodesBaseline, - contactsFromRadio: true, - deferDbMerge: true, - deferPathHistory: true, - }), - ); - assertInitConnStillLive(); - applyMeshcoreNodesToUi(newNodes, { fromRadio: true }); - if (identityId) { - repairMeshcoreChannelSenderIdsInStore(identityId); - } - const contactsToUiMs = Math.round(performance.now() - initConnPerfStart); - console.debug( - `[useMeshcoreRuntime] initConn contacts→UI ${contactsToUiMs}ms (${newNodes.size} nodes)`, - ); - assertInitConnStillLive(); - const tcpBurstDeadBridge = isMeshcoreTcpBurstDeadBridge({ - transportType, - burstCaptured: meshcoreTcpInitBurstCapturedRef.current, - bridgeDead: meshcoreTcpBridgeDeadRef.current, - }); - // TCP defers status=configured (and room auto-login) until after channels below. - if (!deferConfiguredUntilRadioInit) { - triggerRoomAutoLoginRef.current(); - } - void deferMeshcoreDbContactMerge(newNodes, previousNodesBaseline); - - if (sequentialRadioInit) { - const skipChannelsForDeadBurst = isMeshcoreTcpBurstDeadBridge({ - transportType, - burstCaptured: meshcoreTcpInitBurstCapturedRef.current, - bridgeDead: meshcoreTcpBridgeDeadRef.current, - }); - if (skipChannelsForDeadBurst) { - console.debug( - '[useMeshcoreRuntime] initConn getChannels skipped (TCP burst-complete, bridge dead)', + conn.getSelfInfo(5000), ); - } else { - assertInitConnStillLive(); - const getChannelsStart = performance.now(); - try { - // Race: peer FIN after burst must not hang getChannels until MESHCORE_INIT_TIMEOUT. - const channelsWork = withTimeout( - conn.getChannels(), - MESHCORE_INIT_TIMEOUT_MS, - 'getChannels', - ); - const rawChannels = await awaitUnlessMeshcoreSetupCancelled( - setupGen, - (async () => { - let settled = false; - const deadWatch = - transportType === 'tcp' - ? new Promise((_, reject) => { - const id = setInterval(() => { - if ( - settled || - !isMeshcoreTcpBurstDeadBridge({ - transportType, - burstCaptured: meshcoreTcpInitBurstCapturedRef.current, - bridgeDead: meshcoreTcpBridgeDeadRef.current, - }) - ) { - return; - } - clearInterval(id); - reject(new Error('meshcore:tcp-write: no active socket')); - }, 20); - // Attach .catch before finally cleanup so a losing race rejection - // cannot become an unhandled rejection after Promise.race settles. - void channelsWork - .catch(() => { - // catch-no-log-ok consumed; original rejection still races via channelsWork - }) - .finally(() => { - settled = true; - clearInterval(id); - }); - }) - : null; - return deadWatch - ? await Promise.race([channelsWork, deadWatch]) - : await channelsWork; - })(), - ); - setChannels( - dedupeChannelPillsByIndex( - rawChannels.map((c) => ({ index: c.channelIdx, name: c.name, secret: c.secret })), - ), - ); - const getChannelsMs = Math.round(performance.now() - getChannelsStart); - console.debug( - `[useMeshcoreRuntime] initConn getChannels ${getChannelsMs}ms (${rawChannels.length} channels)`, - ); - } catch (e) { - if (isMeshcoreSetupAbortError(e)) throw e; - // Burst already held: soft-skip channels on dead bridge rather than abort configured. - if (meshcoreTcpInitBurstCapturedRef.current && isMeshcoreTcpTransportDeadError(e)) { - meshcoreTcpBridgeDeadRef.current = true; - if ( - shouldDeferMeshcoreTcpReconnectAfterBurst({ - burstCaptured: true, - everConfigured: meshcoreEverConfiguredRef.current, - deviceConfigured: meshcoreDeviceConfiguredRef.current, - }) - ) { - meshcoreDeferredReconnectRef.current = true; + observeMeshcoreSetupAbort(parallelSelfInfoPromise); + getContactsStart = performance.now(); + parallelContactsPromise = awaitUnlessMeshcoreSetupCancelled( + setupGen, + (async () => { + if (meshcoreConnectTypeRef.current === 'ble') { + await awaitDualNobleBleMeshtasticSettle(); } - console.warn( - '[useMeshcoreRuntime] getChannels skipped after TCP burst (bridge dead) ' + - errLikeToLogString(e), + return withTimeout(conn.getContacts(), MESHCORE_INIT_TIMEOUT_MS, 'getContacts'); + })(), + ); + observeMeshcoreSetupAbort(parallelContactsPromise); + const channelsPromise = awaitUnlessMeshcoreSetupCancelled( + setupGen, + withTimeout(conn.getChannels(), MESHCORE_INIT_TIMEOUT_MS, 'getChannels'), + ); + void (async () => { + try { + const rawChannels = await channelsPromise; + setChannels( + dedupeChannelPillsByIndex( + rawChannels.map((c) => ({ index: c.channelIdx, name: c.name, secret: c.secret })), + ), ); - } else { - rethrowMeshcoreSetupAbortFromTcpDead(e); + } catch (e) { + if (isMeshcoreSetupAbortError(e)) return; console.warn('[useMeshcoreRuntime] getChannels error ' + errLikeToLogString(e)); - // TCP: a soft getChannels failure must not promote configured on a dead/superseded link - // before burst capture. - if (deferConfiguredUntilRadioInit) { - assertInitConnStillLive(); - } } + })(); + } + + // Show persisted contacts immediately while the radio contact dump runs over BLE. + const dbCacheStart = performance.now(); + let dbCacheNodeCount = 0; + if (driverStoreId) { + try { + const [rows, dbMsgs, savedNodes] = await Promise.all([ + window.electronAPI.db.getMeshcoreContacts(), + loadMeshcoreMessagesForHydration(), + window.electronAPI.db.getNodes(), + ]); + const contactRows = rows as MeshcoreContactDbRow[]; + registerMeshcorePubKeysFromContactDbRows(contactRows); + copyMeshcorePubKeyRegistryToRefs(pubKeyMapRef.current, pubKeyPrefixMapRef.current); + const mapped = repairMeshcoreHydratedMessages( + mapMeshcoreDbRowsToChatMessages(dbMsgs), + meshcoreRoomServerIdsFromContacts(contactRows), + myNodeNumRef.current, + ); + const cachedNodes = buildMeshcoreNodeMapFromDb(contactRows, savedNodes, mapped); + dbCacheNodeCount = cachedNodes.size; + meshcoreLastPersistedNodesRef.current = new Map(cachedNodes); + applyMeshcoreNodesToUi(cachedNodes); + } catch (e) { + if (isMeshcoreSetupAbortError(e)) throw e; + console.warn( + '[useMeshcoreRuntime] initConn db cache hydrate failed ' + errLikeToLogString(e), + ); } } - } + const dbCacheMs = Math.round(performance.now() - dbCacheStart); + console.debug( + `[useMeshcoreRuntime] initConn dbCache→UI ${dbCacheMs}ms (${dbCacheNodeCount} nodes)`, + ); - assertInitConnStillLive(); - if (deferConfiguredUntilRadioInit) { + // TCP: ConnectionDriver.discoverSelf already ran getSelfInfo — reuse to avoid a second + // companion RPC that SoftAP/OpenHop often FINs after (Neal/Fuzzy). + const reusedDiscoverSelf = + sequentialRadioInit && meshcoreConnectTypeRef.current === 'tcp' + ? takeMeshcoreDiscoverSelfCache(conn) + : undefined; + const rawInfo = + reusedDiscoverSelf ?? + (sequentialRadioInit + ? await awaitUnlessMeshcoreSetupCancelled(setupGen, conn.getSelfInfo(5000)) + : await parallelSelfInfoPromise!); + const getSelfInfoMs = Math.round(performance.now() - getSelfInfoStart); + console.debug( + reusedDiscoverSelf + ? `[useMeshcoreRuntime] initConn getSelfInfo ${getSelfInfoMs}ms (reused discoverSelf)` + : `[useMeshcoreRuntime] initConn getSelfInfo ${getSelfInfoMs}ms`, + ); + const info = enrichMeshCoreSelfInfo(rawInfo); + setSelfInfo(info); + setState((prev) => ({ ...prev, status: 'connected' })); + + const myNodeId = pubkeyToNodeId(info.publicKey); + persistMeshcoreSelfNodeId(myNodeId); + tryPersistMeshcorePublicKeyFromRadio(info.publicKey); + const transportType = meshcoreConnectTypeRef.current; + // Latch session readiness after self-info (reconnect / FIN races), but keep UI status at + // `connected` until the contacts dump settles. Promoting `configured` early starts App + // flood-advert, stats poll, and static-GPS writes that interleave with getContacts — + // SoftAP then FINs mid-dump and meshcore.js Ok/Err listeners race (first-connect hang). + const configureBeforeContactsDump = true; setState((prev) => ({ ...prev, - status: 'configured', + myNodeNum: myNodeId, + status: 'connected', connectionLoss: false, serialNeedsReselect: false, })); meshcoreDeviceConfiguredRef.current = true; + meshcoreEverConfiguredRef.current = true; + if (getStoredMeshProtocol() === 'meshcore') { + useDiagnosticsStore.getState().migrateForeignLoraFromZero(myNodeId); + } + + const discovery = { myNodeNum: myNodeId, publicKey: info.publicKey }; + let identityId = opts?.driverIdentityId ?? null; + if (identityId) { + if (meshcoreIngressDetachRef.current) { + meshcoreIngressDetachRef.current(); + meshcoreIngressDetachRef.current = null; + } + finalizeMeshcoreDriverIdentity( + identityId, + meshcoreTransportParams(transportType, {}), + discovery, + ); + meshcoreIdentityIdRef.current = identityId; + setMeshcoreIdentityId(identityId); + } else { + if (meshcoreIngressDetachRef.current) { + meshcoreIngressDetachRef.current(); + } + // MeshCore protocol ingress owns every inbound push; companion RPCs + // (stats, repeater admin) remain hook-owned request/response paths. + const ingress = attachMeshcoreProtocolIngress( + conn as unknown as Connection, + transportType, + {}, + discovery, + ); + meshcoreIngressDetachRef.current = ingress.detach; + identityId = ingress.identityId; + meshcoreIdentityIdRef.current = identityId; + setMeshcoreIdentityId(identityId); + } + if (meshcoreIngestDetachRef.current) { + meshcoreIngestDetachRef.current(); + } if (identityId) { + meshcoreIngestDetachRef.current = attachMeshcoreIngest(identityId, { + onPathUpdated: handleMeshcorePathUpdatedFromIngest, + rawPacketsForHopCorrelation: () => rawPacketsRef.current, + }); setConnection(identityId, { - status: 'configured', - connectionType: 'http', + status: 'connected', + connectionType: transportType === 'tcp' ? 'http' : transportType, myNodeNum: myNodeId, }); } - // Rooms RPCs need a live companion — defer until reconnect after burst-complete FIN. - if (!tcpBurstDeadBridge && !meshcoreTcpBridgeDeadRef.current) { - triggerRoomAutoLoginRef.current(); + + const promoteConfiguredAfterContactsDump = (): void => { + setState((prev) => ({ + ...prev, + myNodeNum: myNodeId, + status: 'configured', + connectionLoss: false, + serialNeedsReselect: false, + })); + meshcoreDeviceConfiguredRef.current = true; + meshcoreEverConfiguredRef.current = true; + if (identityId) { + setConnection(identityId, { + status: 'configured', + connectionType: transportType === 'tcp' ? 'http' : transportType, + myNodeNum: myNodeId, + }); + } + }; + + // TCP: after session latch, peer FIN during contacts dump is tolerated (do not abort initConn). + // Before latch (should not happen for TCP now), dead bridge still aborts. + const assertInitConnStillLive = (): void => { + if (meshcoreSetupGenerationRef.current !== setupGen) { + throw new DOMException(MESHCORE_SETUP_ABORT_MESSAGE, 'AbortError'); + } + if (transportType === 'tcp' && meshcoreDeviceConfiguredRef.current) { + return; + } + const tcpBurstOk = transportType === 'tcp' && meshcoreTcpInitBurstCapturedRef.current; + if (tcpBurstOk) return; + if (transportType === 'tcp' && meshcoreTcpBridgeDeadRef.current) { + throw new DOMException(MESHCORE_SETUP_ABORT_MESSAGE, 'AbortError'); + } + if (transportType === 'tcp' && connRef.current !== conn) { + throw new DOMException(MESHCORE_SETUP_ABORT_MESSAGE, 'AbortError'); + } + }; + + if (sequentialRadioInit) { + getContactsStart = performance.now(); + } + // TCP only: FIN during dump must not reconnect-loop (BLE/serial ignore this latch path). + meshcoreTcpContactsDumpInFlightRef.current = transportType === 'tcp'; + let contactsRaw: MeshCoreContactRaw[] = []; + let contactsDumpOk = false; + try { + contactsRaw = sequentialRadioInit + ? await awaitUnlessMeshcoreSetupCancelled( + setupGen, + withTimeout(conn.getContacts(), MESHCORE_INIT_TIMEOUT_MS, 'getContacts'), + ) + : await parallelContactsPromise!; + contactsDumpOk = true; + } catch (e) { + if ( + configureBeforeContactsDump && + meshcoreDeviceConfiguredRef.current && + meshcoreSetupGenerationRef.current === setupGen + ) { + if (transportType === 'tcp') { + meshcoreTcpBridgeDeadRef.current = true; + } + console.warn( + '[useMeshcoreRuntime] initConn getContacts failed after configured — keeping session ' + + errLikeToLogString(e), + ); + contactsRaw = []; + contactsDumpOk = false; + } else { + throw e; + } + } finally { + meshcoreTcpContactsDumpInFlightRef.current = false; + } + const getContactsMs = Math.round(performance.now() - getContactsStart); + console.debug( + `[useMeshcoreRuntime] initConn getContacts ${getContactsMs}ms (total ${Math.round(performance.now() - initConnPerfStart)}ms)`, + ); + // Contact payload held (or dump soft-failed) — TCP peer FIN from here is non-fatal. + if (transportType === 'tcp') { + meshcoreTcpInitBurstCapturedRef.current = true; + } + // Fuzzy SoftAP: peer FIN often lands between getContacts resolve and contacts→UI — + // abort before DB/UI work when burst was not yet captured (pre-TCP path above). + assertInitConnStillLive(); + // Do not mark-all-off-radio + apply an empty dump on soft-fail — that would wipe the + // dbCache→UI hydration shown before getContacts (SQLite contacts already on screen). + let previousNodesBaseline = meshcorePreviousNodesBaselineForBuild(); + let newNodes = previousNodesBaseline; + if (contactsDumpOk) { + try { + await window.electronAPI.db.markAllMeshcoreContactsOffRadio(); + } catch (e) { + console.warn( + '[useMeshcoreRuntime] initConn markAllMeshcoreContactsOffRadio failed ' + + errLikeToLogString(e), + ); + } + assertInitConnStillLive(); + const contacts = contactsRaw.map(meshcoreContactRawFromDevice); + setMeshcoreContactsForTelemetry(contacts); + previousNodesBaseline = meshcorePreviousNodesBaselineForBuild(); + newNodes = await awaitUnlessMeshcoreSetupCancelled( + setupGen, + buildNodesFromContacts(contacts, { + self: info, + myNodeId, + previousNodes: previousNodesBaseline, + contactsFromRadio: true, + deferDbMerge: true, + deferPathHistory: true, + }), + ); + assertInitConnStillLive(); + applyMeshcoreNodesToUi(newNodes, { fromRadio: true }); + if (identityId) { + repairMeshcoreChannelSenderIdsInStore(identityId); + } + const contactsToUiMs = Math.round(performance.now() - initConnPerfStart); + console.debug( + `[useMeshcoreRuntime] initConn contacts→UI ${contactsToUiMs}ms (${newNodes.size} nodes)`, + ); + void deferMeshcoreDbContactMerge(newNodes, previousNodesBaseline); } else { console.debug( - '[useMeshcoreRuntime] initConn skip room auto-login (TCP burst-complete, bridge dead)', + '[useMeshcoreRuntime] initConn contacts dump soft-failed — preserving dbCache hydration', ); } - } - - const skipTcpSocketWork = isMeshcoreTcpBurstDeadBridge({ - transportType, - burstCaptured: meshcoreTcpInitBurstCapturedRef.current, - bridgeDead: meshcoreTcpBridgeDeadRef.current, - }); + promoteConfiguredAfterContactsDump(); + assertInitConnStillLive(); + const tcpBurstDeadBridge = isMeshcoreTcpBurstDeadBridge({ + transportType, + burstCaptured: meshcoreTcpInitBurstCapturedRef.current, + bridgeDead: meshcoreTcpBridgeDeadRef.current, + }); + // Room auto-login after contacts sync (not overlapping the dump). TCP also needs a live + // bridge after channels below. + if (transportType !== 'tcp') { + triggerRoomAutoLoginRef.current(); + } - if (!skipTcpSocketWork) { - // Re-resolve map/App GPS after the node store picks up getSelfInfo advert coords (same tick as setNodes is too early). - requestAnimationFrame(() => { - queueMicrotask(() => { - if (meshcoreSetupGenerationRef.current !== setupGen || connRef.current !== conn) { - return; - } - void refreshOurPositionMeshCoreRef.current().catch((e: unknown) => { - if (isMeshcoreTcpTransportDeadError(e) || isMeshcoreSetupAbortError(e)) return; - console.debug( - '[useMeshcoreRuntime] post-connect refreshOurPosition ' + errLikeToLogString(e), + if (sequentialRadioInit) { + const skipChannelsForDeadBurst = isMeshcoreTcpBurstDeadBridge({ + transportType, + burstCaptured: meshcoreTcpInitBurstCapturedRef.current, + bridgeDead: meshcoreTcpBridgeDeadRef.current, + }); + if (skipChannelsForDeadBurst) { + console.debug( + '[useMeshcoreRuntime] initConn getChannels skipped (TCP burst-complete, bridge dead)', + ); + } else { + assertInitConnStillLive(); + const getChannelsStart = performance.now(); + try { + // Race: peer FIN after burst must not hang getChannels until MESHCORE_INIT_TIMEOUT. + const channelsWork = withTimeout( + conn.getChannels(), + MESHCORE_INIT_TIMEOUT_MS, + 'getChannels', ); - }); - void requestTelemetryMeshCoreRef.current(myNodeId).catch((e: unknown) => { - if (isMeshcoreTcpTransportDeadError(e) || isMeshcoreSetupAbortError(e)) return; + const rawChannels = await awaitUnlessMeshcoreSetupCancelled( + setupGen, + (async () => { + let settled = false; + const deadWatch = + transportType === 'tcp' + ? new Promise((_, reject) => { + const id = setInterval(() => { + if ( + settled || + !isMeshcoreTcpBurstDeadBridge({ + transportType, + burstCaptured: meshcoreTcpInitBurstCapturedRef.current, + bridgeDead: meshcoreTcpBridgeDeadRef.current, + }) + ) { + return; + } + clearInterval(id); + reject(new Error('meshcore:tcp-write: no active socket')); + }, 20); + // Attach .catch before finally cleanup so a losing race rejection + // cannot become an unhandled rejection after Promise.race settles. + void channelsWork + .catch(() => { + // catch-no-log-ok consumed; original rejection still races via channelsWork + }) + .finally(() => { + settled = true; + clearInterval(id); + }); + }) + : null; + return deadWatch + ? await Promise.race([channelsWork, deadWatch]) + : await channelsWork; + })(), + ); + setChannels( + dedupeChannelPillsByIndex( + rawChannels.map((c) => ({ index: c.channelIdx, name: c.name, secret: c.secret })), + ), + ); + const getChannelsMs = Math.round(performance.now() - getChannelsStart); console.debug( - '[useMeshcoreRuntime] post-connect self telemetry (altitude) ' + - errLikeToLogString(e), + `[useMeshcoreRuntime] initConn getChannels ${getChannelsMs}ms (${rawChannels.length} channels)`, ); - }); - }); - }); + } catch (e) { + if (isMeshcoreSetupAbortError(e)) throw e; + // Burst already held: soft-skip channels on dead bridge rather than abort configured. + if (meshcoreTcpInitBurstCapturedRef.current && isMeshcoreTcpTransportDeadError(e)) { + meshcoreTcpBridgeDeadRef.current = true; + if ( + shouldDeferMeshcoreTcpReconnectAfterBurst({ + burstCaptured: true, + everConfigured: meshcoreEverConfiguredRef.current, + deviceConfigured: meshcoreDeviceConfiguredRef.current, + initConnInFlight: meshcoreInitConnInFlightRef.current, + }) + ) { + meshcoreDeferredReconnectRef.current = true; + } + console.warn( + '[useMeshcoreRuntime] getChannels skipped after TCP burst (bridge dead) ' + + errLikeToLogString(e), + ); + } else { + rethrowMeshcoreSetupAbortFromTcpDead(e); + console.warn('[useMeshcoreRuntime] getChannels error ' + errLikeToLogString(e)); + assertInitConnStillLive(); + } + } + } + } - // Post-init side-effects — run sequentially to avoid shared Ok/Err listener races - // with user-initiated commands (e.g. config import right after connect). assertInitConnStillLive(); - // Apply saved manual contacts preference - try { - const savedManual = localStorage.getItem(MANUAL_CONTACTS_KEY) === 'true'; - if (savedManual) { - await awaitUnlessMeshcoreSetupCancelled(setupGen, conn.setManualAddContacts()); + // TCP: room auto-login only with a live bridge after the post-configure dump. + // BLE/serial already triggered room auto-login after contacts above. + if (configureBeforeContactsDump && transportType === 'tcp') { + if (!tcpBurstDeadBridge && !meshcoreTcpBridgeDeadRef.current) { + triggerRoomAutoLoginRef.current(); + } else { + console.debug( + '[useMeshcoreRuntime] initConn skip room auto-login (TCP post-configure dump, bridge dead)', + ); } - } catch (e) { - if (isMeshcoreSetupAbortError(e)) throw e; - rethrowMeshcoreSetupAbortFromTcpDead(e); - console.warn( - '[useMeshcoreRuntime] setManualAddContacts (init) error ' + errLikeToLogString(e), - ); } - await awaitUnlessMeshcoreSetupCancelled( - setupGen, - conn.syncDeviceTime().catch((e: unknown) => { - rethrowMeshcoreSetupAbortFromTcpDead(e); - console.warn('[useMeshcoreRuntime] syncDeviceTime error ' + errLikeToLogString(e)); - }), - ); - await awaitUnlessMeshcoreSetupCancelled( - setupGen, - conn - .getBatteryVoltage() - .then(({ batteryMilliVolts }) => { - setSelfInfo((prev) => (prev ? { ...prev, batteryMilliVolts } : prev)); - }) - .catch((e: unknown) => { - rethrowMeshcoreSetupAbortFromTcpDead(e); - console.warn('[useMeshcoreRuntime] getBatteryVoltage error ' + errLikeToLogString(e)); - }), - ); + const skipTcpSocketWork = isMeshcoreTcpBurstDeadBridge({ + transportType, + burstCaptured: meshcoreTcpInitBurstCapturedRef.current, + bridgeDead: meshcoreTcpBridgeDeadRef.current, + }); - try { - await awaitUnlessMeshcoreSetupCancelled(setupGen, refreshMeshcoreAutoaddFromDevice()); - } catch (e) { - if (isMeshcoreSetupAbortError(e)) throw e; - rethrowMeshcoreSetupAbortFromTcpDead(e); - console.warn( - '[useMeshcoreRuntime] refreshMeshcoreAutoaddFromDevice error ' + errLikeToLogString(e), - ); - } + if (!skipTcpSocketWork) { + // Re-resolve map/App GPS after the node store picks up getSelfInfo advert coords (same tick as setNodes is too early). + requestAnimationFrame(() => { + queueMicrotask(() => { + if (meshcoreSetupGenerationRef.current !== setupGen || connRef.current !== conn) { + return; + } + void refreshOurPositionMeshCoreRef.current().catch((e: unknown) => { + if (isMeshcoreTcpTransportDeadError(e) || isMeshcoreSetupAbortError(e)) return; + console.debug( + '[useMeshcoreRuntime] post-connect refreshOurPosition ' + errLikeToLogString(e), + ); + }); + void requestTelemetryMeshCoreRef.current(myNodeId).catch((e: unknown) => { + if (isMeshcoreTcpTransportDeadError(e) || isMeshcoreSetupAbortError(e)) return; + console.debug( + '[useMeshcoreRuntime] post-connect self telemetry (altitude) ' + + errLikeToLogString(e), + ); + }); + }); + }); - try { - const settingsRaw = getAppSettingsRaw(); - const settings = parseStoredJson<{ meshcoreFloodScopeHashtag?: string }>( - settingsRaw, - 'initConn meshcoreFloodScopeHashtag', - ); - const floodHashtag = - typeof settings?.meshcoreFloodScopeHashtag === 'string' - ? settings.meshcoreFloodScopeHashtag - : ''; - if (floodHashtag) { - await awaitUnlessMeshcoreSetupCancelled( - setupGen, - applyMeshcoreFloodScope(conn, floodHashtag), + // Post-init side-effects — run sequentially to avoid shared Ok/Err listener races + // with user-initiated commands (e.g. config import right after connect). + assertInitConnStillLive(); + // Apply saved manual contacts preference + try { + const savedManual = localStorage.getItem(MANUAL_CONTACTS_KEY) === 'true'; + if (savedManual) { + await awaitUnlessMeshcoreSetupCancelled(setupGen, conn.setManualAddContacts()); + } + } catch (e) { + if (isMeshcoreSetupAbortError(e)) throw e; + rethrowMeshcoreSetupAbortFromTcpDead(e); + console.warn( + '[useMeshcoreRuntime] setManualAddContacts (init) error ' + errLikeToLogString(e), ); } - } catch (e) { - if (isMeshcoreSetupAbortError(e)) throw e; - rethrowMeshcoreSetupAbortFromTcpDead(e); - console.warn( - '[useMeshcoreRuntime] initConn reapply flood scope failed ' + errLikeToLogString(e), - ); - } - try { - const deviceInfo = await awaitUnlessMeshcoreSetupCancelled( + await awaitUnlessMeshcoreSetupCancelled( setupGen, - conn.deviceQuery(MESHCORE_DEVICE_QUERY_APP_VER), + conn.syncDeviceTime().catch((e: unknown) => { + rethrowMeshcoreSetupAbortFromTcpDead(e); + console.warn('[useMeshcoreRuntime] syncDeviceTime error ' + errLikeToLogString(e)); + }), + ); + await awaitUnlessMeshcoreSetupCancelled( + setupGen, + conn + .getBatteryVoltage() + .then(({ batteryMilliVolts }) => { + setSelfInfo((prev) => (prev ? { ...prev, batteryMilliVolts } : prev)); + }) + .catch((e: unknown) => { + rethrowMeshcoreSetupAbortFromTcpDead(e); + console.warn( + '[useMeshcoreRuntime] getBatteryVoltage error ' + errLikeToLogString(e), + ); + }), ); - const pathFields = parsePathHashModeFromDeviceQuery(deviceInfo); - setState((prev) => { - const next = { ...prev }; - if (deviceInfo?.firmware_build_date) { - next.firmwareVersion = deviceInfo.firmware_build_date; - } - if (pathFields.firmwareVersion) { - next.firmwareVersion = pathFields.firmwareVersion; - } - const mm = - pathFields.manufacturerModel ?? meshcoreManufacturerModelFromDeviceQuery(deviceInfo); - if (mm) { - next.manufacturerModel = mm; - } - if (isMeshcorePathHashMode(pathFields.pathHashMode)) { - next.pathHashMode = pathFields.pathHashMode; - } - return next; - }); - // Companion is source of truth on connect — do not push App settings onto the radio. - // Sync UI preference FROM the device so a stamped default (1-byte) cannot fight MeshCore app. try { - if (isMeshcorePathHashMode(pathFields.pathHashMode)) { - mergeAppSetting( - 'meshcorePathHashMode', - pathFields.pathHashMode, - 'initConn adopt pathHashMode from radio', + await awaitUnlessMeshcoreSetupCancelled(setupGen, refreshMeshcoreAutoaddFromDevice()); + } catch (e) { + if (isMeshcoreSetupAbortError(e)) throw e; + rethrowMeshcoreSetupAbortFromTcpDead(e); + console.warn( + '[useMeshcoreRuntime] refreshMeshcoreAutoaddFromDevice error ' + + errLikeToLogString(e), + ); + } + + try { + const settingsRaw = getAppSettingsRaw(); + const settings = parseStoredJson<{ meshcoreFloodScopeHashtag?: string }>( + settingsRaw, + 'initConn meshcoreFloodScopeHashtag', + ); + const floodHashtag = + typeof settings?.meshcoreFloodScopeHashtag === 'string' + ? settings.meshcoreFloodScopeHashtag + : ''; + if (floodHashtag) { + await awaitUnlessMeshcoreSetupCancelled( + setupGen, + applyMeshcoreFloodScope(conn, floodHashtag), ); } } catch (e) { if (isMeshcoreSetupAbortError(e)) throw e; + rethrowMeshcoreSetupAbortFromTcpDead(e); console.warn( - '[useMeshcoreRuntime] initConn adopt path hash mode failed ' + errLikeToLogString(e), + '[useMeshcoreRuntime] initConn reapply flood scope failed ' + errLikeToLogString(e), ); } - } catch (e) { - if (isMeshcoreSetupAbortError(e)) throw e; - rethrowMeshcoreSetupAbortFromTcpDead(e); - // catch-no-log-ok deviceQuery optional for firmware string - } - // MQTT private key export runs after other init RPCs to avoid meshcore.js listener races - // (Linux Web Bluetooth is especially sensitive). - try { - await awaitUnlessMeshcoreSetupCancelled( - setupGen, - exportAndPersistMeshcoreMqttIdentity(conn, info.publicKey, transportType), - ); - } catch (e) { - if (isMeshcoreSetupAbortError(e)) throw e; - rethrowMeshcoreSetupAbortFromTcpDead(e); - console.warn( - '[useMeshcoreRuntime] initConn MQTT identity export failed ' + errLikeToLogString(e), - ); - } - assertInitConnStillLive(); - maybeAutoLaunchMeshcoreMqttAfterIdentity(); + try { + const deviceInfo = await awaitUnlessMeshcoreSetupCancelled( + setupGen, + conn.deviceQuery(MESHCORE_DEVICE_QUERY_APP_VER), + ); + const pathFields = parsePathHashModeFromDeviceQuery(deviceInfo); + setState((prev) => { + const next = { ...prev }; + if (deviceInfo?.firmware_build_date) { + next.firmwareVersion = deviceInfo.firmware_build_date; + } + if (pathFields.firmwareVersion) { + next.firmwareVersion = pathFields.firmwareVersion; + } + const mm = + pathFields.manufacturerModel ?? + meshcoreManufacturerModelFromDeviceQuery(deviceInfo); + if (mm) { + next.manufacturerModel = mm; + } + if (isMeshcorePathHashMode(pathFields.pathHashMode)) { + next.pathHashMode = pathFields.pathHashMode; + } + return next; + }); - // Proactively fetch any messages that queued while disconnected (no Chat banner). - scheduleMeshcoreWaitingMessagesDrain( - async () => { + // Companion is source of truth on connect — do not push App settings onto the radio. + // Sync UI preference FROM the device so a stamped default (1-byte) cannot fight MeshCore app. try { - await processWaitingMessagesRef.current?.({ showSyncBanner: false }); - } catch (e: unknown) { - // catch-no-log-ok logMeshcoreWaitingMessagesDrainError handles logging - logMeshcoreWaitingMessagesDrainError( - 'initConn: proactive getWaitingMessages failed', - e, - false, + if (isMeshcorePathHashMode(pathFields.pathHashMode)) { + mergeAppSetting( + 'meshcorePathHashMode', + pathFields.pathHashMode, + 'initConn adopt pathHashMode from radio', + ); + } + } catch (e) { + if (isMeshcoreSetupAbortError(e)) throw e; + console.warn( + '[useMeshcoreRuntime] initConn adopt path hash mode failed ' + + errLikeToLogString(e), ); } - }, - { - isMounted: () => meshcoreHookMountedRef.current, - onDeferredChange: setWaitingMessagesDrainDeferred, - }, - ); + } catch (e) { + if (isMeshcoreSetupAbortError(e)) throw e; + rethrowMeshcoreSetupAbortFromTcpDead(e); + // catch-no-log-ok deviceQuery optional for firmware string + } - // Periodic safety-net poll in case the device never re-sends event 131. - if (meshcoreWaitingMessagesPollRef.current) - clearInterval(meshcoreWaitingMessagesPollRef.current); - meshcoreWaitingMessagesPollRef.current = setInterval(() => { - if (!meshcoreHookMountedRef.current) return; - if (!shouldRunMeshcoreWaitingMessagesPeriodicPoll(waitingMessagesCountRef.current)) { - return; + // MQTT private key export runs after other init RPCs to avoid meshcore.js listener races + // (Linux Web Bluetooth is especially sensitive). + try { + await awaitUnlessMeshcoreSetupCancelled( + setupGen, + exportAndPersistMeshcoreMqttIdentity(conn, info.publicKey, transportType), + ); + } catch (e) { + if (isMeshcoreSetupAbortError(e)) throw e; + rethrowMeshcoreSetupAbortFromTcpDead(e); + console.warn( + '[useMeshcoreRuntime] initConn MQTT identity export failed ' + errLikeToLogString(e), + ); } + assertInitConnStillLive(); + maybeAutoLaunchMeshcoreMqttAfterIdentity(); + + // Proactively fetch any messages that queued while disconnected (no Chat banner). scheduleMeshcoreWaitingMessagesDrain( async () => { try { @@ -2673,7 +2711,7 @@ export function useMeshcoreRuntime() { } catch (e: unknown) { // catch-no-log-ok logMeshcoreWaitingMessagesDrainError handles logging logMeshcoreWaitingMessagesDrainError( - 'periodic getWaitingMessages failed', + 'initConn: proactive getWaitingMessages failed', e, false, ); @@ -2684,17 +2722,47 @@ export function useMeshcoreRuntime() { onDeferredChange: setWaitingMessagesDrainDeferred, }, ); - }, MESHCORE_WAITING_MESSAGES_POLL_MS); - meshcoreRoomReconnectSyncRef.current(); - } else { - console.debug( - '[useMeshcoreRuntime] initConn TCP burst-complete with dead bridge — skip post-connect RPCs', - ); - // Do not auto-launch MQTT here: identity export was skipped with the dead bridge. - // Reconnect's full init will export JWT then call maybeAutoLaunchMeshcoreMqttAfterIdentity. + // Periodic safety-net poll in case the device never re-sends event 131. + if (meshcoreWaitingMessagesPollRef.current) + clearInterval(meshcoreWaitingMessagesPollRef.current); + meshcoreWaitingMessagesPollRef.current = setInterval(() => { + if (!meshcoreHookMountedRef.current) return; + if (!shouldRunMeshcoreWaitingMessagesPeriodicPoll(waitingMessagesCountRef.current)) { + return; + } + scheduleMeshcoreWaitingMessagesDrain( + async () => { + try { + await processWaitingMessagesRef.current?.({ showSyncBanner: false }); + } catch (e: unknown) { + // catch-no-log-ok logMeshcoreWaitingMessagesDrainError handles logging + logMeshcoreWaitingMessagesDrainError( + 'periodic getWaitingMessages failed', + e, + false, + ); + } + }, + { + isMounted: () => meshcoreHookMountedRef.current, + onDeferredChange: setWaitingMessagesDrainDeferred, + }, + ); + }, MESHCORE_WAITING_MESSAGES_POLL_MS); + + meshcoreRoomReconnectSyncRef.current(); + } else { + console.debug( + '[useMeshcoreRuntime] initConn TCP burst-complete with dead bridge — skip post-connect RPCs', + ); + // Do not auto-launch MQTT here: identity export was skipped with the dead bridge. + // Reconnect's full init will export JWT then call maybeAutoLaunchMeshcoreMqttAfterIdentity. + } + meshcoreEverConfiguredRef.current = true; + } finally { + meshcoreInitConnInFlightRef.current = false; } - meshcoreEverConfiguredRef.current = true; }, [ awaitUnlessMeshcoreSetupCancelled, @@ -2773,6 +2841,8 @@ export function useMeshcoreRuntime() { if (type === 'tcp') { meshcoreTcpBridgeDeadRef.current = false; meshcoreTcpInitBurstCapturedRef.current = false; + meshcoreTcpContactsDumpInFlightRef.current = false; + meshcoreInitConnInFlightRef.current = false; if (!opts?.preserveReconnectState) { meshcoreDeferredReconnectRef.current = false; } @@ -7075,6 +7145,11 @@ export function useMeshcoreRuntime() { } if (pos.source === 'static' && connRef.current) { + // Do not write SetAdvertLatLon during initConn contacts dump — SoftAP FINs mid-dump when + // GPS/stats/advert RPCs interleave with getContacts (meshcore.js shared Ok/Err). + if (meshcoreInitConnInFlightRef.current) { + return pos; + } sendPositionToDeviceMeshCore(pos.lat, pos.lon).catch((e: unknown) => { console.debug( '[useMeshcoreRuntime] refreshOurPosition setAdvertLatLong non-fatal ' + @@ -7330,12 +7405,22 @@ export function useMeshcoreRuntime() { const connectingTcp = meshcoreConnectTypeRef.current === 'tcp'; if (!storedTcp && !connectingTcp) return; meshcoreTcpBridgeDeadRef.current = true; + // Post-configure contacts dump: keep the configured session; do not reconnect-loop. + if (meshcoreTcpContactsDumpInFlightRef.current) { + console.debug( + source === 'write' + ? '[useMeshcoreRuntime] TCP write-dead during post-configure contacts dump — keep configured' + : '[useMeshcoreRuntime] TCP closed during post-configure contacts dump — keep configured', + ); + return; + } // Defer while this open can still finish from the contacts burst (!everConfigured covers // late IPC after premature deviceConfigured; !deviceConfigured covers mid-reconnect opens). const defer = shouldDeferMeshcoreTcpReconnectAfterBurst({ burstCaptured: meshcoreTcpInitBurstCapturedRef.current, everConfigured: meshcoreEverConfiguredRef.current, deviceConfigured: meshcoreDeviceConfiguredRef.current, + initConnInFlight: meshcoreInitConnInFlightRef.current, }); if (defer) { meshcoreDeferredReconnectRef.current = true; From b59977346bed35f6acc28a0c4592320d7e83ccb5 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 06:01:52 -0600 Subject: [PATCH 4/7] test(meshcore): expect connected until contacts dump settles Align stats serial channel-hydration coverage with configure-after-dump. --- src/renderer/hooks/useMeshcoreRuntime.stats.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/hooks/useMeshcoreRuntime.stats.test.tsx b/src/renderer/hooks/useMeshcoreRuntime.stats.test.tsx index 0b36f8b0e..0acda3d45 100644 --- a/src/renderer/hooks/useMeshcoreRuntime.stats.test.tsx +++ b/src/renderer/hooks/useMeshcoreRuntime.stats.test.tsx @@ -299,7 +299,7 @@ describe('useMeshcoreRuntime stats parsing', () => { }); await waitFor(() => { - expect(result.current.state.status).toBe('configured'); + expect(result.current.state.status).toBe('connected'); }); expect(result.current.channels).toEqual([]); expect(getChannelsMock).not.toHaveBeenCalled(); @@ -311,6 +311,7 @@ describe('useMeshcoreRuntime stats parsing', () => { }); await waitFor(() => { + expect(result.current.state.status).toBe('configured'); expect(result.current.channels).toEqual([{ index: 1, name: 'Ops', secret: opsSecret }]); }); }); From dfa8f1b134851f984a3086a2282a24b1f9e11458 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 06:06:10 -0600 Subject: [PATCH 5/7] feat: auto-connect remembered TCP/HTTP RF links on launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #805 — extend ProtocolAutoConnectCoordinator cold-start to match serial/BLE for Meshtastic tcp and MeshCore http last connections. --- src/renderer/components/ConnectionPanel.tsx | 3 +- .../hooks/useProtocolRfAutoConnect.test.tsx | 107 ++++++++++++++++++ .../hooks/useProtocolRfAutoConnect.ts | 30 ++++- 3 files changed, 134 insertions(+), 6 deletions(-) diff --git a/src/renderer/components/ConnectionPanel.tsx b/src/renderer/components/ConnectionPanel.tsx index 876d09f05..e1d2d801b 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -1688,7 +1688,8 @@ export default function ConnectionPanel({ } else { maybeNotifyPrimaryBleAutoConnectSettled(); } - // HTTP: do not auto-trigger — show one-click reconnect card instead + // HTTP/TCP launch auto-connect is owned by ProtocolAutoConnectCoordinator / + // useProtocolRfAutoConnect; this panel path only settles (reconnect card if needed). }, [protocol, isLinux, t, capabilities.hasReticulumInterfaceConfig, suppressMountAutoConnect]); // Cleanup timeout on unmount diff --git a/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx b/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx index c46533acc..dc08e87d2 100644 --- a/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx +++ b/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx @@ -215,3 +215,110 @@ describe('useProtocolRfAutoConnect cold-start skip paths', () => { expect(connectAutomatic).not.toHaveBeenCalled(); }); }); + +describe('useProtocolRfAutoConnect cold-start TCP/HTTP', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadLastConnection.mockReturnValue(null); + mocks.loadLastBleDeviceId.mockReturnValue(null); + mocks.awaitReticulumBleCoexistenceClear.mockResolvedValue(undefined); + mocks.dualNobleBleBothRadiosConfigured.mockReturnValue(false); + mocks.getNobleBleDualRadioPrimaryProtocol.mockReturnValue(null); + mocks.isNobleBleDualRadioSecondary.mockReturnValue(false); + mocks.isRendererNobleBlePlatform.mockReturnValue(true); + mocks.meshcoreTargetsSharedMeshtasticBlePeripheral.mockReturnValue(false); + mocks.tryGetMeshtasticSession.mockReturnValue({ connectAutomatic: vi.fn() }); + mocks.tryGetMeshcoreSession.mockReturnValue({ connectAutomatic: vi.fn() }); + mocks.awaitNobleBleProtocolSettle.mockResolvedValue(undefined); + vi.spyOn(window.electronAPI, 'getPlatform').mockReturnValue('darwin'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('auto-connects meshtastic TCP with stored address and skips Reticulum gate', async () => { + mocks.loadLastConnection.mockReturnValue({ + type: 'tcp', + httpAddress: '192.168.1.50:4403', + }); + const connectAutomatic = vi.fn().mockResolvedValue(undefined); + + renderHook(() => { + useProtocolRfAutoConnect({ + protocol: 'meshtastic', + state: disconnected, + connectAutomatic, + }); + }); + + await waitFor(() => { + expect(connectAutomatic).toHaveBeenCalledWith('tcp', '192.168.1.50:4403'); + }); + expect(mocks.awaitReticulumBleCoexistenceClear).not.toHaveBeenCalled(); + }); + + it('auto-connects meshcore HTTP (TCP/IP) with stored address and skips Reticulum gate', async () => { + mocks.loadLastConnection.mockReturnValue({ + type: 'http', + httpAddress: '10.0.0.1:5000', + }); + const connectAutomatic = vi.fn().mockResolvedValue(undefined); + + renderHook(() => { + useProtocolRfAutoConnect({ + protocol: 'meshcore', + state: disconnected, + connectAutomatic, + }); + }); + + await waitFor(() => { + expect(connectAutomatic).toHaveBeenCalledWith('http', '10.0.0.1:5000'); + }); + expect(mocks.awaitReticulumBleCoexistenceClear).not.toHaveBeenCalled(); + }); + + it('skips TCP auto-connect when httpAddress is blank and settles primary gate if needed', async () => { + mocks.loadLastConnection.mockReturnValue({ type: 'tcp', httpAddress: ' ' }); + mocks.dualNobleBleBothRadiosConfigured.mockReturnValue(true); + mocks.getNobleBleDualRadioPrimaryProtocol.mockReturnValue('meshtastic'); + const connectAutomatic = vi.fn(); + + renderHook(() => { + useProtocolRfAutoConnect({ + protocol: 'meshtastic', + state: disconnected, + connectAutomatic, + }); + }); + + await waitFor(() => { + expect(mocks.notifyNobleBlePrimaryAutoConnectSettled).toHaveBeenCalled(); + }); + expect(connectAutomatic).not.toHaveBeenCalled(); + expect(mocks.awaitReticulumBleCoexistenceClear).not.toHaveBeenCalled(); + }); + + it('skips HTTP auto-connect when httpAddress is missing', async () => { + mocks.loadLastConnection.mockReturnValue({ type: 'http' }); + const connectAutomatic = vi.fn(); + + renderHook(() => { + useProtocolRfAutoConnect({ + protocol: 'meshcore', + state: disconnected, + connectAutomatic, + }); + }); + + await waitFor(() => { + expect(mocks.loadLastConnection).toHaveBeenCalledWith('meshcore'); + }); + // Session wait is async — give the startup path a tick to finish without connecting. + await waitFor(() => { + expect(mocks.tryGetMeshcoreSession).toHaveBeenCalled(); + }); + expect(connectAutomatic).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/hooks/useProtocolRfAutoConnect.ts b/src/renderer/hooks/useProtocolRfAutoConnect.ts index 99d12650d..32b372740 100644 --- a/src/renderer/hooks/useProtocolRfAutoConnect.ts +++ b/src/renderer/hooks/useProtocolRfAutoConnect.ts @@ -78,11 +78,12 @@ function watchPrimaryAutoConnectAttempt(protocol: MeshProtocol, attempt: Promise } /** - * Starts a remembered serial or Noble BLE RF connection once per mounted protocol. + * Starts a remembered serial, Noble BLE, or TCP/HTTP RF connection once per mounted protocol. * - * Failure point: a remembered serial device may be unavailable, or BLE may never finish - * connecting. Fallback: serial retries its remembered Noble BLE peripheral; the 30-second - * timeout releases the attempt. Failures are logged because no panel-local UI is mounted. + * Failure point: a remembered serial device may be unavailable, BLE may never finish + * connecting, or a TCP/HTTP host may be unreachable. Fallback: serial retries its remembered + * Noble BLE peripheral; TCP/HTTP has no transport fallback. The 30-second timeout releases + * the attempt. Failures are logged because no panel-local UI is mounted. */ export function useProtocolRfAutoConnect({ protocol, @@ -134,7 +135,10 @@ export function useProtocolRfAutoConnect({ console.warn(`[useProtocolRfAutoConnect] ${protocol} auto-connect timed out after 30s`); }, 30_000); }; - const onAutoConnectFailed = (error: unknown, transport: 'serial' | 'ble' = 'ble') => { + const onAutoConnectFailed = ( + error: unknown, + transport: 'serial' | 'ble' | 'tcp' | 'http' = 'ble', + ) => { clearAutoConnectTimeout(); console.warn( `[useProtocolRfAutoConnect] ${protocol} ${transport} auto-connect failed: ${errLikeToLogString(error)}`, @@ -227,6 +231,13 @@ export function useProtocolRfAutoConnect({ notifyPrimaryAutoConnectSettledIfNeeded(protocol); }; + const onTcpAutoConnectFailed = (error: unknown) => { + if (isCancelled()) return; + const transport = lastConnection.type === 'http' ? 'http' : 'tcp'; + onAutoConnectFailed(error, transport); + notifyPrimaryAutoConnectSettledIfNeeded(protocol); + }; + const runStartupAutoConnect = async (): Promise => { const ready = await waitForProtocolSession(protocol); if (isCancelled()) return; @@ -256,6 +267,15 @@ export function useProtocolRfAutoConnect({ return; } + if (lastConnection.type === 'http' || lastConnection.type === 'tcp') { + const addr = lastConnection.httpAddress?.trim(); + if (addr) { + startAutoConnectTimeout(); + connectAutomaticRef.current(lastConnection.type, addr).catch(onTcpAutoConnectFailed); + return; + } + } + notifyPrimaryAutoConnectSettledIfNeeded(protocol); }; From a91f76dec964e1a24bebfc9638f3bcbb280ce4a5 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 06:11:12 -0600 Subject: [PATCH 6/7] style: fix Prettier spacing in FuzzyChaos credits --- docs/credits.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/credits.md b/docs/credits.md index 66e7a084f..043d14495 100644 --- a/docs/credits.md +++ b/docs/credits.md @@ -12,7 +12,7 @@ - [Soord](https://github.com/soord) - [WB3IHY](https://github.com/WB3IHY) - [Letark](https://github.com/Letark) - Apple code signing & notarization CI -- FuzzyChaos (ADL) - Donation for devices +- FuzzyChaos (ADL) - Donation for devices ## Colorado Mesh From 56b92e606cbd18049703911a0ce80f950b5d14b9 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 06:21:44 -0600 Subject: [PATCH 7/7] fix: harden TCP auto-connect cancel and SoftAP init races Address PR review: platform-parity tests, gen-scoped initConn in-flight, TCP-only contacts soft-fail, Reconnect cancel, and FuzzyChaos contributors sync. --- package.json | 3 +- .../components/ConnectionPanel.test.tsx | 130 +++++++++----- src/renderer/components/ConnectionPanel.tsx | 14 ++ .../hooks/useMeshcoreRuntime.stats.test.tsx | 17 +- .../hooks/useProtocolRfAutoConnect.test.tsx | 169 ++++++++++-------- .../hooks/useProtocolRfAutoConnect.ts | 58 ++++-- src/renderer/lib/bleReconnectHelper.ts | 6 +- .../lib/meshcore/meshcoreTcpInitBurst.test.ts | 11 ++ .../lib/meshcore/meshcoreTcpInitBurst.ts | 7 +- .../loraRfReconnectParity.contract.test.ts | 1 - .../useMeshcoreRuntime.reconnect.test.ts | 2 +- src/renderer/runtime/useMeshcoreRuntime.ts | 31 +++- 12 files changed, 294 insertions(+), 155 deletions(-) diff --git a/package.json b/package.json index 7eb82bad8..de1ff07e8 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "megabear - KD5IHC", "Soord https://github.com/soord", "WB3IHY https://github.com/WB3IHY", - "Letark https://github.com/Letark" + "Letark https://github.com/Letark", + "FuzzyChaos (ADL)" ], "main": "dist-electron/main/index.js", "scripts": { diff --git a/src/renderer/components/ConnectionPanel.test.tsx b/src/renderer/components/ConnectionPanel.test.tsx index be8fbc206..6add6b7ae 100644 --- a/src/renderer/components/ConnectionPanel.test.tsx +++ b/src/renderer/components/ConnectionPanel.test.tsx @@ -1577,61 +1577,109 @@ describe('ConnectionPanel active-protocol-first BLE auto-connect', () => { } }); - it('cancels deferred meshcore BLE auto-connect when user cancels before primary settle completes', async () => { - const { restore } = mockMacNoblePlatform(); - const mcConnKey = 'mesh-client:lastConnection:meshcore'; - const mtConnKey = 'mesh-client:lastConnection:meshtastic'; - localStorage.setItem(protocolKey, 'meshtastic'); - localStorage.setItem('mesh-client:lastBleDevice:meshcore', 'meshcore-ble-device'); - localStorage.setItem('mesh-client:lastBleDevice:meshtastic', 'meshtastic-ble-device'); - localStorage.setItem( - mcConnKey, - JSON.stringify({ type: 'ble', bleDeviceId: 'meshcore-ble-device' }), - ); + // OS-specific: dual-Noble deferred BLE auto-connect (Cancel-before-primary-settle) exists only + // on macOS/Windows Noble. Linux uses Web Bluetooth and skips remembered BLE cold-start in + // useProtocolRfAutoConnect (`!isLinux`) — there is no equivalent dual-radio deferral to cancel. + it.each(['darwin', 'win32'] as const)( + 'cancels deferred meshcore BLE auto-connect when user cancels before primary settle completes (%s)', + async (platform) => { + const { restore } = mockNobleBlePlatform(platform); + const mcConnKey = 'mesh-client:lastConnection:meshcore'; + const mtConnKey = 'mesh-client:lastConnection:meshtastic'; + localStorage.setItem(protocolKey, 'meshtastic'); + localStorage.setItem('mesh-client:lastBleDevice:meshcore', 'meshcore-ble-device'); + localStorage.setItem('mesh-client:lastBleDevice:meshtastic', 'meshtastic-ble-device'); + localStorage.setItem( + mcConnKey, + JSON.stringify({ type: 'ble', bleDeviceId: 'meshcore-ble-device' }), + ); + localStorage.setItem( + mtConnKey, + JSON.stringify({ type: 'ble', bleDeviceId: 'meshtastic-ble-device' }), + ); + const onAutoConnect = vi.fn().mockResolvedValue(undefined); + const dualNoble = await import('../lib/meshcoreDualNobleBleInit'); + dualNoble.resetNobleBleConnectMutexForTests(); + dualNoble.initNobleBleDualRadioStartup(); + let releaseSettle!: () => void; + vi.spyOn(dualNoble, 'awaitNobleBlePrimaryAutoConnectSettled').mockImplementation( + () => + new Promise((resolve) => { + releaseSettle = resolve; + }), + ); + + const user = userEvent.setup(); + try { + render( + , + ); + + // Secondary BLE waits on Meshtastic — connecting UI exposes Cancel. + const cancelBtn = await screen.findByRole('button', { name: /^Cancel$/i }); + await user.click(cancelBtn); + + releaseSettle(); + await Promise.resolve(); + await Promise.resolve(); + expect(onAutoConnect).not.toHaveBeenCalled(); + } finally { + localStorage.removeItem(mcConnKey); + localStorage.removeItem(mtConnKey); + localStorage.removeItem('mesh-client:lastBleDevice:meshcore'); + localStorage.removeItem('mesh-client:lastBleDevice:meshtastic'); + dualNoble.resetNobleBleConnectMutexForTests(); + restore(); + } + }, + ); + + it('cancels ProtocolAutoConnectCoordinator when user clicks Reconnect with a pending last connection', async () => { + const user = userEvent.setup(); + const lastConnKey = 'mesh-client:lastConnection:meshtastic'; localStorage.setItem( - mtConnKey, - JSON.stringify({ type: 'ble', bleDeviceId: 'meshtastic-ble-device' }), - ); - const onAutoConnect = vi.fn().mockResolvedValue(undefined); - const dualNoble = await import('../lib/meshcoreDualNobleBleInit'); - dualNoble.resetNobleBleConnectMutexForTests(); - dualNoble.initNobleBleDualRadioStartup(); - let releaseSettle!: () => void; - vi.spyOn(dualNoble, 'awaitNobleBlePrimaryAutoConnectSettled').mockImplementation( - () => - new Promise((resolve) => { - releaseSettle = resolve; - }), + lastConnKey, + JSON.stringify({ type: 'tcp', httpAddress: '192.168.1.50:4403' }), ); + const gate = await import('../lib/protocolRfAutoConnectGate'); + const cancelSpy = vi.spyOn(gate, 'cancelProtocolRfAutoConnect'); + const onConnect = vi.fn().mockResolvedValue(undefined); - const user = userEvent.setup(); try { render( , ); - // Secondary BLE waits on Meshtastic — connecting UI exposes Cancel. - const cancelBtn = await screen.findByRole('button', { name: /^Cancel$/i }); - await user.click(cancelBtn); + await user.click(await screen.findByRole('button', { name: /^Reconnect$/i })); - releaseSettle(); - await Promise.resolve(); - await Promise.resolve(); - expect(onAutoConnect).not.toHaveBeenCalled(); + expect(cancelSpy).toHaveBeenCalledWith('meshtastic'); + await waitFor(() => { + expect(onConnect).toHaveBeenCalledWith('tcp', '192.168.1.50:4403'); + }); + const cancelOrder = cancelSpy.mock.invocationCallOrder[0]; + const connectOrder = onConnect.mock.invocationCallOrder[0]; + if (cancelOrder === undefined || connectOrder === undefined) { + throw new Error('expected cancelProtocolRfAutoConnect and onConnect call order'); + } + expect(cancelOrder).toBeLessThan(connectOrder); } finally { - localStorage.removeItem(mcConnKey); - localStorage.removeItem(mtConnKey); - localStorage.removeItem('mesh-client:lastBleDevice:meshcore'); - localStorage.removeItem('mesh-client:lastBleDevice:meshtastic'); - dualNoble.resetNobleBleConnectMutexForTests(); - restore(); + cancelSpy.mockRestore(); + localStorage.removeItem(lastConnKey); } }); diff --git a/src/renderer/components/ConnectionPanel.tsx b/src/renderer/components/ConnectionPanel.tsx index e1d2d801b..2a8a227f5 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -1702,6 +1702,20 @@ export default function ConnectionPanel({ const handleReconnect = useCallback(() => { if (!lastConnection) return; + // Same cancel as handleConnect — Reconnect must not race deferred ProtocolAutoConnectCoordinator + // BLE/serial auto-connect (orphan socket / connectType flip). + autoConnectCancelRef.current = true; + cancelProtocolRfAutoConnect(protocol); + if (isAutoConnectingRef.current) { + console.debug('[ConnectionPanel] cancelling in-flight BLE auto-connect for reconnect'); + } + isAutoConnectingRef.current = false; + setIsAutoConnecting(false); + setAutoConnectBleTarget(null); + if (autoConnectTimeoutRef.current) { + clearTimeout(autoConnectTimeoutRef.current); + autoConnectTimeoutRef.current = null; + } setError(null); if (lastConnection.type === 'ble') { diff --git a/src/renderer/hooks/useMeshcoreRuntime.stats.test.tsx b/src/renderer/hooks/useMeshcoreRuntime.stats.test.tsx index 0acda3d45..b34cc293d 100644 --- a/src/renderer/hooks/useMeshcoreRuntime.stats.test.tsx +++ b/src/renderer/hooks/useMeshcoreRuntime.stats.test.tsx @@ -272,15 +272,10 @@ describe('useMeshcoreRuntime stats parsing', () => { it('serial awaits channel hydration after contacts before connect resolves', async () => { const contactsGate = deferred<[]>(); + const channelsGate = deferred<{ channelIdx: number; name: string; secret: Uint8Array }[]>(); const opsSecret = new Uint8Array(16).fill(0x11); getContactsMock.mockReturnValueOnce(contactsGate.promise); - getChannelsMock.mockResolvedValueOnce([ - { - channelIdx: 1, - name: 'Ops', - secret: opsSecret, - }, - ]); + getChannelsMock.mockReturnValueOnce(channelsGate.promise); const port = makeMockSerialPort(); Object.defineProperty(navigator, 'serial', { @@ -306,6 +301,14 @@ describe('useMeshcoreRuntime stats parsing', () => { expect(result.current.nodes.size).toBe(0); contactsGate.resolve([]); + await waitFor(() => { + expect(result.current.state.status).toBe('configured'); + }); + // Status is configured after contacts, but initConn still owns getChannels — suppress stats. + expect(getStatsCoreMock).not.toHaveBeenCalled(); + expect(getChannelsMock).toHaveBeenCalled(); + + channelsGate.resolve([{ channelIdx: 1, name: 'Ops', secret: opsSecret }]); await act(async () => { await connectPromise; }); diff --git a/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx b/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx index dc08e87d2..3128d5784 100644 --- a/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx +++ b/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx @@ -230,95 +230,110 @@ describe('useProtocolRfAutoConnect cold-start TCP/HTTP', () => { mocks.tryGetMeshtasticSession.mockReturnValue({ connectAutomatic: vi.fn() }); mocks.tryGetMeshcoreSession.mockReturnValue({ connectAutomatic: vi.fn() }); mocks.awaitNobleBleProtocolSettle.mockResolvedValue(undefined); - vi.spyOn(window.electronAPI, 'getPlatform').mockReturnValue('darwin'); }); afterEach(() => { vi.restoreAllMocks(); }); - it('auto-connects meshtastic TCP with stored address and skips Reticulum gate', async () => { - mocks.loadLastConnection.mockReturnValue({ - type: 'tcp', - httpAddress: '192.168.1.50:4403', - }); - const connectAutomatic = vi.fn().mockResolvedValue(undefined); - - renderHook(() => { - useProtocolRfAutoConnect({ - protocol: 'meshtastic', - state: disconnected, - connectAutomatic, + it.each(['linux', 'darwin', 'win32'] as const)( + 'auto-connects meshtastic TCP with stored address and skips Reticulum gate (%s)', + async (platform) => { + vi.spyOn(window.electronAPI, 'getPlatform').mockReturnValue(platform); + mocks.loadLastConnection.mockReturnValue({ + type: 'tcp', + httpAddress: '192.168.1.50:4403', }); - }); - - await waitFor(() => { - expect(connectAutomatic).toHaveBeenCalledWith('tcp', '192.168.1.50:4403'); - }); - expect(mocks.awaitReticulumBleCoexistenceClear).not.toHaveBeenCalled(); - }); - - it('auto-connects meshcore HTTP (TCP/IP) with stored address and skips Reticulum gate', async () => { - mocks.loadLastConnection.mockReturnValue({ - type: 'http', - httpAddress: '10.0.0.1:5000', - }); - const connectAutomatic = vi.fn().mockResolvedValue(undefined); - - renderHook(() => { - useProtocolRfAutoConnect({ - protocol: 'meshcore', - state: disconnected, - connectAutomatic, + const connectAutomatic = vi.fn().mockResolvedValue(undefined); + + renderHook(() => { + useProtocolRfAutoConnect({ + protocol: 'meshtastic', + state: disconnected, + connectAutomatic, + }); }); - }); - - await waitFor(() => { - expect(connectAutomatic).toHaveBeenCalledWith('http', '10.0.0.1:5000'); - }); - expect(mocks.awaitReticulumBleCoexistenceClear).not.toHaveBeenCalled(); - }); - - it('skips TCP auto-connect when httpAddress is blank and settles primary gate if needed', async () => { - mocks.loadLastConnection.mockReturnValue({ type: 'tcp', httpAddress: ' ' }); - mocks.dualNobleBleBothRadiosConfigured.mockReturnValue(true); - mocks.getNobleBleDualRadioPrimaryProtocol.mockReturnValue('meshtastic'); - const connectAutomatic = vi.fn(); - renderHook(() => { - useProtocolRfAutoConnect({ - protocol: 'meshtastic', - state: disconnected, - connectAutomatic, + await waitFor(() => { + expect(connectAutomatic).toHaveBeenCalledWith('tcp', '192.168.1.50:4403'); + }); + expect(mocks.awaitReticulumBleCoexistenceClear).not.toHaveBeenCalled(); + }, + ); + + it.each(['linux', 'darwin', 'win32'] as const)( + 'auto-connects meshcore HTTP (TCP/IP) with stored address and skips Reticulum gate (%s)', + async (platform) => { + vi.spyOn(window.electronAPI, 'getPlatform').mockReturnValue(platform); + mocks.loadLastConnection.mockReturnValue({ + type: 'http', + httpAddress: '10.0.0.1:5000', + }); + const connectAutomatic = vi.fn().mockResolvedValue(undefined); + + renderHook(() => { + useProtocolRfAutoConnect({ + protocol: 'meshcore', + state: disconnected, + connectAutomatic, + }); }); - }); - - await waitFor(() => { - expect(mocks.notifyNobleBlePrimaryAutoConnectSettled).toHaveBeenCalled(); - }); - expect(connectAutomatic).not.toHaveBeenCalled(); - expect(mocks.awaitReticulumBleCoexistenceClear).not.toHaveBeenCalled(); - }); - it('skips HTTP auto-connect when httpAddress is missing', async () => { - mocks.loadLastConnection.mockReturnValue({ type: 'http' }); - const connectAutomatic = vi.fn(); + await waitFor(() => { + expect(connectAutomatic).toHaveBeenCalledWith('http', '10.0.0.1:5000'); + }); + expect(mocks.awaitReticulumBleCoexistenceClear).not.toHaveBeenCalled(); + }, + ); + + it.each(['linux', 'darwin', 'win32'] as const)( + 'skips TCP auto-connect when httpAddress is blank and settles primary gate if needed (%s)', + async (platform) => { + vi.spyOn(window.electronAPI, 'getPlatform').mockReturnValue(platform); + mocks.loadLastConnection.mockReturnValue({ type: 'tcp', httpAddress: ' ' }); + mocks.dualNobleBleBothRadiosConfigured.mockReturnValue(true); + mocks.getNobleBleDualRadioPrimaryProtocol.mockReturnValue('meshtastic'); + const connectAutomatic = vi.fn(); + + renderHook(() => { + useProtocolRfAutoConnect({ + protocol: 'meshtastic', + state: disconnected, + connectAutomatic, + }); + }); - renderHook(() => { - useProtocolRfAutoConnect({ - protocol: 'meshcore', - state: disconnected, - connectAutomatic, + await waitFor(() => { + expect(mocks.notifyNobleBlePrimaryAutoConnectSettled).toHaveBeenCalled(); + }); + expect(connectAutomatic).not.toHaveBeenCalled(); + expect(mocks.awaitReticulumBleCoexistenceClear).not.toHaveBeenCalled(); + }, + ); + + it.each(['linux', 'darwin', 'win32'] as const)( + 'skips HTTP auto-connect when httpAddress is missing (%s)', + async (platform) => { + vi.spyOn(window.electronAPI, 'getPlatform').mockReturnValue(platform); + mocks.loadLastConnection.mockReturnValue({ type: 'http' }); + const connectAutomatic = vi.fn(); + + renderHook(() => { + useProtocolRfAutoConnect({ + protocol: 'meshcore', + state: disconnected, + connectAutomatic, + }); }); - }); - await waitFor(() => { - expect(mocks.loadLastConnection).toHaveBeenCalledWith('meshcore'); - }); - // Session wait is async — give the startup path a tick to finish without connecting. - await waitFor(() => { - expect(mocks.tryGetMeshcoreSession).toHaveBeenCalled(); - }); - expect(connectAutomatic).not.toHaveBeenCalled(); - }); + await waitFor(() => { + expect(mocks.loadLastConnection).toHaveBeenCalledWith('meshcore'); + }); + // Session wait is async — give the startup path a tick to finish without connecting. + await waitFor(() => { + expect(mocks.tryGetMeshcoreSession).toHaveBeenCalled(); + }); + expect(connectAutomatic).not.toHaveBeenCalled(); + }, + ); }); diff --git a/src/renderer/hooks/useProtocolRfAutoConnect.ts b/src/renderer/hooks/useProtocolRfAutoConnect.ts index 32b372740..b94301014 100644 --- a/src/renderer/hooks/useProtocolRfAutoConnect.ts +++ b/src/renderer/hooks/useProtocolRfAutoConnect.ts @@ -135,6 +135,10 @@ export function useProtocolRfAutoConnect({ console.warn(`[useProtocolRfAutoConnect] ${protocol} auto-connect timed out after 30s`); }, 30_000); }; + const onAutoConnectCancelled = () => { + clearAutoConnectTimeout(); + notifyPrimaryAutoConnectSettledIfNeeded(protocol); + }; const onAutoConnectFailed = ( error: unknown, transport: 'serial' | 'ble' | 'tcp' | 'http' = 'ble', @@ -144,13 +148,15 @@ export function useProtocolRfAutoConnect({ `[useProtocolRfAutoConnect] ${protocol} ${transport} auto-connect failed: ${errLikeToLogString(error)}`, ); }; + const isAutoConnectAbortError = (error: unknown): boolean => + error instanceof DOMException && error.name === 'AbortError'; const runBleAutoConnect = async (bleId: string) => { if (protocol === 'meshcore' && meshcoreTargetsSharedMeshtasticBlePeripheral(bleId)) { console.debug( `[useProtocolRfAutoConnect] meshcore BLE auto-connect skipped — same peripheral as Meshtastic (${bleId})`, ); - notifyPrimaryAutoConnectSettledIfNeeded(protocol); + onAutoConnectCancelled(); return; } @@ -161,7 +167,7 @@ export function useProtocolRfAutoConnect({ console.debug( `[useProtocolRfAutoConnect] ${protocol} BLE auto-connect cancelled after coexistence wait`, ); - notifyPrimaryAutoConnectSettledIfNeeded(protocol); + onAutoConnectCancelled(); return; } @@ -171,7 +177,7 @@ export function useProtocolRfAutoConnect({ console.debug( `[useProtocolRfAutoConnect] ${protocol} BLE auto-connect cancelled after primary settle`, ); - notifyPrimaryAutoConnectSettledIfNeeded(protocol); + onAutoConnectCancelled(); return; } const primary = getNobleBleDualRadioPrimaryProtocol(); @@ -183,7 +189,7 @@ export function useProtocolRfAutoConnect({ console.debug( `[useProtocolRfAutoConnect] ${protocol} BLE auto-connect cancelled after protocol settle`, ); - notifyPrimaryAutoConnectSettledIfNeeded(protocol); + onAutoConnectCancelled(); return; } } @@ -192,7 +198,7 @@ export function useProtocolRfAutoConnect({ console.debug( `[useProtocolRfAutoConnect] ${protocol} BLE auto-connect cancelled before connect`, ); - notifyPrimaryAutoConnectSettledIfNeeded(protocol); + onAutoConnectCancelled(); return; } @@ -213,7 +219,10 @@ export function useProtocolRfAutoConnect({ }; const onSerialAutoConnectFailed = (error: unknown) => { - if (isCancelled()) return; + if (isCancelled() || isAutoConnectAbortError(error)) { + onAutoConnectCancelled(); + return; + } if (lastBleId && !isLinux) { console.warn( `[useProtocolRfAutoConnect] serial auto-connect failed for ${protocol}; falling back to BLE noble scan: ${errLikeToLogString(error)}`, @@ -224,7 +233,13 @@ export function useProtocolRfAutoConnect({ bleDeviceName: lastConnection.bleDeviceName, }; saveLastConnection(protocol, bleLast); - runBleAutoConnect(lastBleId).catch(onAutoConnectFailed); + runBleAutoConnect(lastBleId).catch((bleError: unknown) => { + if (isCancelled() || isAutoConnectAbortError(bleError)) { + onAutoConnectCancelled(); + return; + } + onAutoConnectFailed(bleError); + }); return; } onAutoConnectFailed(error, 'serial'); @@ -232,7 +247,10 @@ export function useProtocolRfAutoConnect({ }; const onTcpAutoConnectFailed = (error: unknown) => { - if (isCancelled()) return; + if (isCancelled() || isAutoConnectAbortError(error)) { + onAutoConnectCancelled(); + return; + } const transport = lastConnection.type === 'http' ? 'http' : 'tcp'; onAutoConnectFailed(error, transport); notifyPrimaryAutoConnectSettledIfNeeded(protocol); @@ -240,17 +258,20 @@ export function useProtocolRfAutoConnect({ const runStartupAutoConnect = async (): Promise => { const ready = await waitForProtocolSession(protocol); - if (isCancelled()) return; + if (isCancelled()) { + onAutoConnectCancelled(); + return; + } if (!ready) { console.warn( `[useProtocolRfAutoConnect] ${protocol} auto-connect skipped — runtime session never registered`, ); - notifyPrimaryAutoConnectSettledIfNeeded(protocol); + onAutoConnectCancelled(); return; } if (isCancelled()) { - notifyPrimaryAutoConnectSettledIfNeeded(protocol); + onAutoConnectCancelled(); return; } @@ -263,7 +284,13 @@ export function useProtocolRfAutoConnect({ } if (lastConnection.type === 'ble' && lastBleId && !isLinux) { - runBleAutoConnect(lastBleId).catch(onAutoConnectFailed); + runBleAutoConnect(lastBleId).catch((error: unknown) => { + if (isCancelled() || isAutoConnectAbortError(error)) { + onAutoConnectCancelled(); + return; + } + onAutoConnectFailed(error); + }); return; } @@ -276,11 +303,14 @@ export function useProtocolRfAutoConnect({ } } - notifyPrimaryAutoConnectSettledIfNeeded(protocol); + onAutoConnectCancelled(); }; runStartupAutoConnect().catch((error: unknown) => { - if (isCancelled()) return; + if (isCancelled() || isAutoConnectAbortError(error)) { + onAutoConnectCancelled(); + return; + } onAutoConnectFailed(error); notifyPrimaryAutoConnectSettledIfNeeded(protocol); }); diff --git a/src/renderer/lib/bleReconnectHelper.ts b/src/renderer/lib/bleReconnectHelper.ts index 0aeaa2ea6..83f13ba88 100644 --- a/src/renderer/lib/bleReconnectHelper.ts +++ b/src/renderer/lib/bleReconnectHelper.ts @@ -167,7 +167,11 @@ export async function reconnectBleWithScan( await connect(); return; } catch (err) { - if (isMeshcoreSetupAbortError(err)) { + // AbortError (setup cancel / RF auto-connect cancel) must not fall through to scan. + if ( + isMeshcoreSetupAbortError(err) || + (err instanceof DOMException && err.name === 'AbortError') + ) { throw err; } const message = errLikeToLogString(err); diff --git a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts index 624190985..cac0afca2 100644 --- a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts +++ b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts @@ -75,6 +75,17 @@ describe('shouldDeferMeshcoreTcpReconnectAfterBurst', () => { ).toBe(true); }); + it('defers while initConn is in flight during contacts dump before burstCaptured', () => { + expect( + shouldDeferMeshcoreTcpReconnectAfterBurst({ + burstCaptured: false, + everConfigured: true, + deviceConfigured: true, + initConnInFlight: true, + }), + ).toBe(true); + }); + it('does not defer once both everConfigured and deviceConfigured are true', () => { expect( shouldDeferMeshcoreTcpReconnectAfterBurst({ diff --git a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts index 36ce9ad41..4aeaddc3a 100644 --- a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts +++ b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts @@ -18,8 +18,9 @@ export function isMeshcoreTcpBurstDeadBridge(opts: { * Uses !deviceConfigured so mid-reconnect opens (everConfigured already true) still defer. * Mid-reconnect FIN often races getContacts resolve (burst flag not set yet) — defer whenever * everConfigured && !deviceConfigured even without burstCaptured. - * Configure-before-dump: deviceConfigured+everConfigured are both true during getChannels — - * still defer while initConn is in flight after the burst so peer FIN does not bump setup gen. + * Configure-before-dump: deviceConfigured+everConfigured are both true during getChannels / + * the contacts-dump window (burstCaptured may still be false) — still defer while initConn is + * in flight so peer FIN does not bump setup gen. */ export function shouldDeferMeshcoreTcpReconnectAfterBurst(opts: { burstCaptured: boolean; @@ -27,7 +28,7 @@ export function shouldDeferMeshcoreTcpReconnectAfterBurst(opts: { deviceConfigured: boolean; initConnInFlight?: boolean; }): boolean { - if (opts.initConnInFlight && opts.burstCaptured) { + if (opts.initConnInFlight) { return true; } if (opts.deviceConfigured && opts.everConfigured) { diff --git a/src/renderer/runtime/loraRfReconnectParity.contract.test.ts b/src/renderer/runtime/loraRfReconnectParity.contract.test.ts index 4b85680cd..b100fb6d4 100644 --- a/src/renderer/runtime/loraRfReconnectParity.contract.test.ts +++ b/src/renderer/runtime/loraRfReconnectParity.contract.test.ts @@ -58,7 +58,6 @@ describe('LoRa RF reconnect parity (MeshCore ↔ Meshtastic)', () => { expect(MESHCORE).toContain('TCP closed during post-configure contacts dump — keep configured'); expect(MESHCORE).toContain('preserving dbCache hydration'); expect(MESHCORE).toContain('promoteConfiguredAfterContactsDump'); - expect(MESHCORE).toContain('keep UI status at'); expect(MESHCORE).toMatch( /meshcoreDeviceConfiguredRef\.current = true[\s\S]*?getContacts[\s\S]*?promoteConfiguredAfterContactsDump/, ); diff --git a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts index ed07792c7..08b64c395 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts @@ -51,7 +51,7 @@ describe('useMeshcoreRuntime auto-reconnect (regression)', () => { 'initConn TCP burst-complete with dead bridge — skip post-connect RPCs', ); expect(RUNTIME_SOURCE).toMatch( - /meshcoreEverConfiguredRef\.current = true;\s*\n\s*\} finally \{\s*\n\s*meshcoreInitConnInFlightRef\.current = false;/, + /meshcoreEverConfiguredRef\.current = true;[\s\S]*?\} finally \{[\s\S]*?meshcoreInitConnInFlightRef\.current = false;/, ); }); diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index cbaa9d1c0..a72ed895a 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -619,8 +619,11 @@ export function useMeshcoreRuntime() { /** * True for the duration of `initConn`. After configure-before-dump, peer FIN once the contacts * burst is held must defer reconnect (not bump setup gen) until init finishes. + * Cleared in finally only when `meshcoreInitConnInFlightSetupGenRef` still matches the + * owning setupGen (superseded opens must not clear a newer initConn). */ const meshcoreInitConnInFlightRef = useRef(false); + const meshcoreInitConnInFlightSetupGenRef = useRef(null); /** * Active session configured (Meshtastic `deviceConfiguredRef` parity). Cleared on disconnect / * new connect; set when initConn reaches configured so post-configure Noble drops during @@ -972,10 +975,13 @@ export function useMeshcoreRuntime() { }); }, MESHCORE_STATS_POLL_MS); - // Initial stats fetch on connect - void fetchAndUpdateLocalStats().catch((e: unknown) => { - console.warn('[useMeshcoreRuntime] initial stats fetch failed ' + errLikeToLogString(e)); - }); + // Initial stats fetch on connect — skip while initConn still owns the radio (configure- + // before-dump can already show configured; interval will pick up once init finishes). + if (!meshcoreInitConnInFlightRef.current) { + void fetchAndUpdateLocalStats().catch((e: unknown) => { + console.warn('[useMeshcoreRuntime] initial stats fetch failed ' + errLikeToLogString(e)); + }); + } } return () => { if (meshcoreStatsPollRef.current) { @@ -2068,6 +2074,7 @@ export function useMeshcoreRuntime() { const initConn = useCallback( async (conn: MeshCoreConnection, setupGen: number, opts?: { driverIdentityId?: string }) => { meshcoreInitConnInFlightRef.current = true; + meshcoreInitConnInFlightSetupGenRef.current = setupGen; try { connRef.current = conn; meshcoreConnEventListenersTeardownRef.current?.(); @@ -2339,14 +2346,14 @@ export function useMeshcoreRuntime() { : await parallelContactsPromise!; contactsDumpOk = true; } catch (e) { + // Soft-fail is TCP SoftAP/OpenHop only — BLE/serial getContacts failures must abort. if ( + transportType === 'tcp' && configureBeforeContactsDump && meshcoreDeviceConfiguredRef.current && meshcoreSetupGenerationRef.current === setupGen ) { - if (transportType === 'tcp') { - meshcoreTcpBridgeDeadRef.current = true; - } + meshcoreTcpBridgeDeadRef.current = true; console.warn( '[useMeshcoreRuntime] initConn getContacts failed after configured — keeping session ' + errLikeToLogString(e), @@ -2761,7 +2768,10 @@ export function useMeshcoreRuntime() { } meshcoreEverConfiguredRef.current = true; } finally { - meshcoreInitConnInFlightRef.current = false; + if (meshcoreInitConnInFlightSetupGenRef.current === setupGen) { + meshcoreInitConnInFlightRef.current = false; + meshcoreInitConnInFlightSetupGenRef.current = null; + } } }, [ @@ -2838,11 +2848,14 @@ export function useMeshcoreRuntime() { if (!opts?.preserveReconnectState) { meshcoreConnectionParamsRef.current = null; } + // Release superseded initConn for all RF transports (not TCP-only) so stats/GPS gates + // and reconnect deferral cannot stick after BLE/serial prepare aborts a prior open. + meshcoreInitConnInFlightRef.current = false; + meshcoreInitConnInFlightSetupGenRef.current = null; if (type === 'tcp') { meshcoreTcpBridgeDeadRef.current = false; meshcoreTcpInitBurstCapturedRef.current = false; meshcoreTcpContactsDumpInFlightRef.current = false; - meshcoreInitConnInFlightRef.current = false; if (!opts?.preserveReconnectState) { meshcoreDeferredReconnectRef.current = false; }