diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3155fceff..b72467b61 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2511,7 +2511,8 @@ function AppContent() { const sendScheduledAdvert = () => { // SoftAP/OpenHop: configured session may have a dead TCP bridge after contacts FIN. // Flood advert would tcp-write-fail and thrash reconnect. - if (isMeshcoreTcpSoftApDeadAccepted()) { + const softAp = isMeshcoreTcpSoftApDeadAccepted(); + if (softAp) { console.debug('[App] auto flood advert skipped (SoftAP dead bridge)'); return; } diff --git a/src/renderer/hooks/openMeshCoreTransport.ts b/src/renderer/hooks/openMeshCoreTransport.ts index e3d235181..d7faf42f5 100644 --- a/src/renderer/hooks/openMeshCoreTransport.ts +++ b/src/renderer/hooks/openMeshCoreTransport.ts @@ -19,6 +19,8 @@ export async function openMeshCoreTransport( blePeripheralId?: string; host?: string; portSignature?: string | null; + /** SoftAP user-TX reopen: skip ConnectionDriver discoverSelf so user RPC is first. */ + skipDiscoverSelf?: boolean; }, ): Promise { const params = meshcoreTransportParams(type, { @@ -26,7 +28,9 @@ export async function openMeshCoreTransport( host: opts.host, portSignature: opts.portSignature ?? undefined, }); - const identityId = await connectionDriver.connect('meshcore', params); + const identityId = await connectionDriver.connect('meshcore', params, { + skipDiscoverSelf: opts.skipDiscoverSelf === true, + }); const conn = connectionDriver.getHandle(identityId); if (!conn) { await connectionDriver.disconnect(identityId).catch((e: unknown) => { diff --git a/src/renderer/hooks/useSendMessage.ts b/src/renderer/hooks/useSendMessage.ts index a32e88b13..5ca83400b 100644 --- a/src/renderer/hooks/useSendMessage.ts +++ b/src/renderer/hooks/useSendMessage.ts @@ -148,8 +148,11 @@ export function useSendMessage( } if (!handle) { - console.warn('[useSendMessage] no handle for', identityId); - return; + // SoftAP dead bridge may still send via quiet reopen (handle recreated on open). + if (!(identity.protocol.type === 'meshcore' && isMeshcoreTcpSoftApDeadAccepted())) { + console.warn('[useSendMessage] no handle for', identityId); + return; + } } const isMeshtastic = identity.protocol.type === 'meshtastic'; @@ -204,6 +207,72 @@ export function useSendMessage( const wireText = resolvedOutbound.wireText; + if (isMeshcore && isMeshcoreTcpSoftApDeadAccepted()) { + void (async () => { + try { + const applySoftApSendResult = (res: { packetId?: number }): void => { + const resolvedId = res.packetId != null ? String(res.packetId >>> 0) : provisionalId; + if (res.packetId != null && resolvedId !== provisionalId) { + renameMessageId(identityId, provisionalId, resolvedId); + } + updateMessageStatus(identityId, resolvedId, 'acked'); + persistMeshcoreOutboundRow( + { ...record, id: resolvedId, status: 'acked' }, + myNodeNum, + meshcoreSenderName, + 'acked', + res.packetId != null ? res.packetId >>> 0 : undefined, + ); + }; + const runTx = tryGetMeshcoreSession()?.runMeshcoreUserTxWithLiveTcp; + if (!runTx) { + await tryGetMeshcoreSession()?.ensureTcpLiveForUserTx?.(); + const liveHandle = connectionDriver.getHandle(identityId); + if (!liveHandle) { + throw new Error('MeshCore TCP live reopen produced no handle'); + } + const sendPromise = identity.protocol.sendMessage(liveHandle, { + text: wireText, + channelIndex, + destination, + destinationPubKey, + replyTo, + }); + trackMeshcoreTcpUserTxSend(sendPromise); + applySoftApSendResult(await sendPromise); + return; + } + const res = await runTx(async () => { + const liveHandle = connectionDriver.getHandle(identityId); + if (!liveHandle) { + throw new Error('MeshCore TCP live reopen produced no handle'); + } + return identity.protocol.sendMessage(liveHandle, { + text: wireText, + channelIndex, + destination, + destinationPubKey, + replyTo, + }); + }); + // Only after SoftAP retry loop resolves — not inside the parked op (latch-retry + // may re-run the send; premature acked would stick if attempt 2 failed). + applySoftApSendResult(res); + } catch (e: unknown) { + const errMsg = errLikeToLogString(e); + console.warn('[useSendMessage] SoftAP live reopen failed ' + errMsg); + updateMessageStatus(identityId, provisionalId, 'failed', errMsg); + persistMeshcoreOutboundRow(record, myNodeNum, meshcoreSenderName, 'failed'); + } + })(); + return; + } + + if (!handle) { + console.warn('[useSendMessage] no handle for', identityId); + return; + } + const finishSend = ( sendHandle: NonNullable, opts?: { trackForSoftApLiveWindow?: boolean }, @@ -280,25 +349,6 @@ export function useSendMessage( ); }; - if (isMeshcore && isMeshcoreTcpSoftApDeadAccepted()) { - void (async () => { - try { - await tryGetMeshcoreSession()?.ensureTcpLiveForUserTx?.(); - const liveHandle = connectionDriver.getHandle(identityId); - if (!liveHandle) { - throw new Error('MeshCore TCP live reopen produced no handle'); - } - finishSend(liveHandle, { trackForSoftApLiveWindow: true }); - } catch (e: unknown) { - const errMsg = errLikeToLogString(e); - console.warn('[useSendMessage] SoftAP live reopen failed ' + errMsg); - updateMessageStatus(identityId, provisionalId, 'failed', errMsg); - persistMeshcoreOutboundRow(record, myNodeNum, meshcoreSenderName, 'failed'); - } - })(); - return; - } - finishSend(handle); }, [identityId, addToast, t], diff --git a/src/renderer/lib/drivers/ConnectionDriver.ts b/src/renderer/lib/drivers/ConnectionDriver.ts index 5818ed104..3fa9593b9 100644 --- a/src/renderer/lib/drivers/ConnectionDriver.ts +++ b/src/renderer/lib/drivers/ConnectionDriver.ts @@ -155,7 +155,11 @@ export class ConnectionDriver { this.registerTransportKeys(identityId, provisionalKey, resolvedKey); } - async connect(protocolType: string, params: TransportParams): Promise { + async connect( + protocolType: string, + params: TransportParams, + opts?: { skipDiscoverSelf?: boolean }, + ): Promise { const protocol = getProtocolForType(protocolType); if (!protocol) throw new Error(`Unknown protocol: ${protocolType}`); @@ -195,7 +199,8 @@ export class ConnectionDriver { } let info: DiscoveryInfo | undefined; - if (protocol.discoverSelf) { + // SoftAP user-TX reopen: skip getSelfInfo so the parked user command is the first RPC. + if (protocol.discoverSelf && !opts?.skipDiscoverSelf) { try { info = await protocol.discoverSelf(handle); } catch (err) { diff --git a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts index 1e1a1aed9..ff0a3a39c 100644 --- a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts +++ b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts @@ -1,14 +1,24 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { isMeshcoreTcpTransportDeadError } from '../bleConnectErrors'; import { + clearMeshcoreSoftApPendingUserTx, + decideSoftApUserTxAfterEnsureFailure, + hasMeshcoreSoftApPendingUserTx, isMeshcoreTcpBurstDeadBridge, isMeshcoreTcpSoftApDeadAccepted, + MESHCORE_TCP_SOFTAP_BRIDGE_DIED_DURING_OP, notifyMeshcoreTcpLiveForUserTx, notifyMeshcoreTcpWriteDead, rejectMeshcoreTcpLiveForUserTx, + runMeshcoreSoftApPendingUserTx, + runWithMeshcoreTcpDeadWriteRetry, + setMeshcoreSoftApPendingUserTx, setMeshcoreTcpSoftApDeadAccepted, setMeshcoreTcpWriteDeadListener, + settleSoftApPendingResult, shouldDeferMeshcoreTcpReconnectAfterBurst, + throwIfMeshcoreTcpBridgeDiedDuringSoftApOp, trackMeshcoreTcpUserTxSend, waitForMeshcoreTcpLiveForUserTx, yieldToMeshcoreTcpUserTxSends, @@ -151,6 +161,185 @@ describe('SoftAP user-TX live window', () => { await live; expect(order).toEqual(['live', 'sent', 'after-yield']); }); + + it('waits for nested ensureTcpLive→send track (SoftAP chat reopen race)', async () => { + const order: string[] = []; + // Mirrors useSendMessage: await ensureTcpLive (wait), then another async hop, then track. + const ensureTcpLive = waitForMeshcoreTcpLiveForUserTx(5_000); + const sendPath = (async () => { + await ensureTcpLive; + order.push('live'); + await Promise.resolve(); // nested IIFE hop + order.push('track'); + const send = Promise.resolve().then(() => { + order.push('sent'); + }); + trackMeshcoreTcpUserTxSend(send); + })(); + await Promise.resolve(); + notifyMeshcoreTcpLiveForUserTx(); + await yieldToMeshcoreTcpUserTxSends({ waitForFirstSendMs: 50 }); + order.push('after-yield'); + await sendPath; + expect(order).toEqual(['live', 'track', 'sent', 'after-yield']); + }); +}); + +describe('runWithMeshcoreTcpDeadWriteRetry', () => { + it('retries once on meshcore tcp-write dead errors', async () => { + const ensureLive = vi.fn(() => Promise.resolve()); + let attempts = 0; + const result = await runWithMeshcoreTcpDeadWriteRetry(ensureLive, () => { + attempts += 1; + if (attempts === 1) { + return Promise.reject(new Error('meshcore:tcp-write: no active socket')); + } + return Promise.resolve('ok'); + }); + expect(result).toBe('ok'); + expect(ensureLive).toHaveBeenCalledTimes(2); + expect(attempts).toBe(2); + }); + + it('does not retry non-transport errors', async () => { + const ensureLive = vi.fn(() => Promise.resolve()); + await expect( + runWithMeshcoreTcpDeadWriteRetry(ensureLive, () => + Promise.reject(new Error('channel name too long')), + ), + ).rejects.toThrow('channel name too long'); + expect(ensureLive).toHaveBeenCalledTimes(1); + }); + + it('caps attempts at 2 and rethrows the last dead-write error', async () => { + const ensureLive = vi.fn(() => Promise.resolve()); + await expect( + runWithMeshcoreTcpDeadWriteRetry(ensureLive, () => + Promise.reject(new Error("Error invoking remote method 'meshcore:tcp-write'")), + ), + ).rejects.toThrow(/meshcore:tcp-write/); + expect(ensureLive).toHaveBeenCalledTimes(2); + }); +}); + +describe('SoftAP pending user TX slot', () => { + afterEach(() => { + clearMeshcoreSoftApPendingUserTx(); + }); + + it('runs parked op as first SoftAP RPC and settles the result promise', async () => { + const order: string[] = []; + const resultPromise = setMeshcoreSoftApPendingUserTx(() => { + order.push('op'); + return Promise.resolve(42); + }); + expect(hasMeshcoreSoftApPendingUserTx()).toBe(true); + const ran = await runMeshcoreSoftApPendingUserTx(); + expect(ran).toBe(true); + await expect(resultPromise).resolves.toBe(42); + expect(order).toEqual(['op']); + expect(hasMeshcoreSoftApPendingUserTx()).toBe(false); + }); + + it('runs concurrent parked ops in FIFO order', async () => { + const order: string[] = []; + const first = setMeshcoreSoftApPendingUserTx(() => { + order.push('a'); + return Promise.resolve(1); + }); + const second = setMeshcoreSoftApPendingUserTx(() => { + order.push('b'); + return Promise.resolve(2); + }); + expect(hasMeshcoreSoftApPendingUserTx()).toBe(true); + const ran = await runMeshcoreSoftApPendingUserTx(); + expect(ran).toBe(true); + await expect(first).resolves.toBe(1); + await expect(second).resolves.toBe(2); + expect(order).toEqual(['a', 'b']); + expect(hasMeshcoreSoftApPendingUserTx()).toBe(false); + }); + + it('clear rejects all parked TX that never ran', async () => { + const first = setMeshcoreSoftApPendingUserTx(() => Promise.resolve('never-a')); + const second = setMeshcoreSoftApPendingUserTx(() => Promise.resolve('never-b')); + clearMeshcoreSoftApPendingUserTx(new Error('aborted')); + await expect(first).rejects.toThrow('aborted'); + await expect(second).rejects.toThrow('aborted'); + expect(hasMeshcoreSoftApPendingUserTx()).toBe(false); + }); +}); + +describe('throwIfMeshcoreTcpBridgeDiedDuringSoftApOp', () => { + it('throws a transport-dead error when the latch flips during the parked op', () => { + expect(() => { + throwIfMeshcoreTcpBridgeDiedDuringSoftApOp(false, true); + }).toThrow(MESHCORE_TCP_SOFTAP_BRIDGE_DIED_DURING_OP); + try { + throwIfMeshcoreTcpBridgeDiedDuringSoftApOp(false, true); + } catch (e: unknown) { + expect(isMeshcoreTcpTransportDeadError(e)).toBe(true); + } + }); + + it('is a no-op when the latch was already dead or stayed live', () => { + expect(() => { + throwIfMeshcoreTcpBridgeDiedDuringSoftApOp(true, true); + }).not.toThrow(); + expect(() => { + throwIfMeshcoreTcpBridgeDiedDuringSoftApOp(false, false); + }).not.toThrow(); + expect(() => { + throwIfMeshcoreTcpBridgeDiedDuringSoftApOp(true, false); + }).not.toThrow(); + }); +}); + +describe('decideSoftApUserTxAfterEnsureFailure', () => { + it('returns the parked value when the op already fulfilled (late latch — no double-send)', () => { + expect( + decideSoftApUserTxAfterEnsureFailure({ + opSettlement: { status: 'fulfilled', value: 42 }, + }), + ).toEqual({ action: 'return', value: 42 }); + }); + + it('retries only when the parked op rejected with transport-dead', () => { + expect( + decideSoftApUserTxAfterEnsureFailure({ + opSettlement: { + status: 'rejected', + reason: new Error(MESHCORE_TCP_SOFTAP_BRIDGE_DIED_DURING_OP), + }, + }), + ).toEqual({ action: 'retry' }); + }); + + it('rethrows non-transport parked-op failures without retry', () => { + const err = new Error('channel name too long'); + expect( + decideSoftApUserTxAfterEnsureFailure({ + opSettlement: { status: 'rejected', reason: err }, + }), + ).toEqual({ action: 'throw', error: err }); + }); +}); + +describe('settleSoftApPendingResult', () => { + it('reports fulfilled and rejected settlements', async () => { + await expect(settleSoftApPendingResult(Promise.resolve('ok'))).resolves.toEqual({ + status: 'fulfilled', + value: 'ok', + }); + const boom = new Error('meshcore:tcp-write: no active socket'); + const rejected = Promise.reject(boom); + // Attach early so vitest does not flag an unhandled rejection before settle. + void rejected.catch(() => undefined); + await expect(settleSoftApPendingResult(rejected)).resolves.toEqual({ + status: 'rejected', + reason: boom, + }); + }); }); describe('notifyMeshcoreTcpWriteDead', () => { diff --git a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts index 3fc9f2860..53cc612fe 100644 --- a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts +++ b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts @@ -3,6 +3,7 @@ * Shared predicates for initConn / getChannels skip paths and reconnect deferral. */ +import { isMeshcoreTcpTransportDeadError } from '@/renderer/lib/bleConnectErrors'; import { MS_PER_SECOND } from '@/shared/timeConstants'; export function isMeshcoreTcpBurstDeadBridge(opts: { @@ -64,6 +65,12 @@ export function isMeshcoreTcpSoftApDeadAccepted(): boolean { /** SoftAP user TX: wait for getSelfInfo live window before getContacts / peer FIN. */ export const MESHCORE_TCP_USER_TX_LIVE_TIMEOUT_MS = 20 * MS_PER_SECOND; +/** + * SoftAP/OpenHop often FINs a reconnect that starts immediately after the prior session. + * Match reconnect attempt-1 backoff so the companion accepts a new TCP live window for chat TX. + */ +export const MESHCORE_TCP_SOFTAP_USER_TX_REOPEN_DELAY_MS = 2 * MS_PER_SECOND; + interface TcpLiveWaiter { resolve: () => void; reject: (err: Error) => void; @@ -136,14 +143,30 @@ export function trackMeshcoreTcpUserTxSend(sendPromise: Promise): void * Ordering vs `ensureTcpLiveForUserTx` / `useSendMessage`: * 1. initConn calls `notifyMeshcoreTcpLiveForUserTx()` (resolves waiters), * 2. then `yieldToMeshcoreTcpUserTxSends()`. - * Waiters resume in `useSendMessage` after `ensureTcpLiveForUserTx` and only then call - * `trackMeshcoreTcpUserTxSend`. Two microtask hops let those continuations register before - * we snapshot `inFlightUserTxSends` — do not remove the hops without reworking registration - * to happen before the waiter resolves. + * Waiters resume in `ensureTcpLiveForUserTx`, which returns into a nested `useSendMessage` + * async IIFE that only then calls `trackMeshcoreTcpUserTxSend`. That is **three** microtask + * hops (notify → ensureTcpLive → useSendMessage), not two — SoftAP reopen used to snapshot + * an empty send list and start getContacts before track registered. */ -export async function yieldToMeshcoreTcpUserTxSends(): Promise { +export async function yieldToMeshcoreTcpUserTxSends(opts?: { + /** SoftAP user-TX reopen: wait briefly for a late-tracked send after the microtask hops. */ + waitForFirstSendMs?: number; +}): Promise { await Promise.resolve(); await Promise.resolve(); + await Promise.resolve(); + const waitMs = opts?.waitForFirstSendMs ?? 0; + if (waitMs > 0 && inFlightUserTxSends.length === 0) { + const deadline = Date.now() + waitMs; + while (Date.now() < deadline) { + // length mutates via trackMeshcoreTcpUserTxSend while we poll. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- module-level array + if (inFlightUserTxSends.length > 0) break; + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } + } const pending = inFlightUserTxSends.slice(); if (pending.length > 0) { await Promise.allSettled(pending); @@ -161,3 +184,143 @@ export function setMeshcoreTcpWriteDeadListener( export function notifyMeshcoreTcpWriteDead(): void { meshcoreTcpWriteDeadListener?.(); } + +/** + * SoftAP user TX: ensure live TCP, run op, retry once on dead-bridge write errors. + * Non-transport failures are not retried. + */ +export async function runWithMeshcoreTcpDeadWriteRetry( + ensureLive: () => Promise, + op: () => Promise, +): Promise { + let lastErr: unknown; + for (let attempt = 0; attempt < 2; attempt++) { + await ensureLive(); + try { + return await op(); + } catch (e: unknown) { + lastErr = e; + if (!isMeshcoreTcpTransportDeadError(e)) throw e; + } + } + throw lastErr; +} + +interface SoftApPendingUserTx { + run: () => Promise; + reject: (reason?: unknown) => void; +} + +/** FIFO parked SoftAP user commands (concurrent sends share one quiet reopen). */ +const softApPendingUserTxQueue: SoftApPendingUserTx[] = []; + +/** + * Park a SoftAP user command so SoftAP `initConn` can run it (FIFO) as companion RPC(s) + * before getSelfInfo / contacts. Returns a promise that settles when that run completes. + */ +export function setMeshcoreSoftApPendingUserTx(op: () => Promise): Promise { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const resultPromise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + // Avoid unhandled rejection if open fails before the waiter attaches. + void resultPromise.then( + () => undefined, + () => undefined, + ); + softApPendingUserTxQueue.push({ + reject, + run: async () => { + try { + const value = await op(); + resolve(value); + } catch (e: unknown) { + reject(e); + throw e; + } + }, + }); + return resultPromise; +} + +/** SoftAP initConn: run and clear all parked user TX in FIFO order (if any). */ +export async function runMeshcoreSoftApPendingUserTx(): Promise { + let ran = false; + while (softApPendingUserTxQueue.length > 0) { + const pending = softApPendingUserTxQueue.shift(); + if (!pending) break; + await pending.run(); + ran = true; + } + return ran; +} + +/** Clear parked SoftAP TX that will never run (open aborted / ensure failed). */ +export function clearMeshcoreSoftApPendingUserTx(err?: Error): void { + const batch = softApPendingUserTxQueue.splice(0); + if (batch.length === 0) return; + const reason = err ?? new Error('MeshCore SoftAP pending TX cleared'); + for (const pending of batch) { + pending.reject(reason); + } +} + +export function hasMeshcoreSoftApPendingUserTx(): boolean { + return softApPendingUserTxQueue.length > 0; +} + +/** Error message matching {@link isMeshcoreTcpTransportDeadError} for SoftAP latch-retry. */ +export const MESHCORE_TCP_SOFTAP_BRIDGE_DIED_DURING_OP = 'meshcore:tcp-write: no active socket'; + +/** + * SoftAP first-RPC: if the write-dead latch flipped during the parked user op, throw a + * transport-dead error so ensure's live wait rejects. {@link runMeshcoreUserTxWithLiveTcp} + * must still return a fulfilled parked result (no re-run) — late latch after Ok must not + * double-send chat. + */ +export function throwIfMeshcoreTcpBridgeDiedDuringSoftApOp( + bridgeDeadBefore: boolean, + bridgeDeadAfter: boolean, +): void { + if (bridgeDeadAfter && !bridgeDeadBefore) { + throw new Error(MESHCORE_TCP_SOFTAP_BRIDGE_DIED_DURING_OP); + } +} + +export type SoftApOpSettlement = + { status: 'fulfilled'; value: T } | { status: 'rejected'; reason: unknown }; + +/** Settle a parked SoftAP result without throwing (for ensure-failure decision). */ +export async function settleSoftApPendingResult( + resultPromise: Promise, +): Promise> { + try { + return { status: 'fulfilled', value: await resultPromise }; + } catch (reason: unknown) { + // catch-no-log-ok settle helper returns rejected status to caller for SoftAP retry decision + return { status: 'rejected', reason }; + } +} + +export type SoftApEnsureFailureDecision = + { action: 'return'; value: T } | { action: 'retry' } | { action: 'throw'; error: unknown }; + +/** + * After SoftAP `ensureTcpLiveForUserTx` fails: never re-run a parked op that already + * completed (would double-send). Retry only when the op never succeeded and rejected + * with a transport-dead error (including clear-with-ensure when ensure was transport-dead). + */ +export function decideSoftApUserTxAfterEnsureFailure(opts: { + opSettlement: SoftApOpSettlement; +}): SoftApEnsureFailureDecision { + if (opts.opSettlement.status === 'fulfilled') { + return { action: 'return', value: opts.opSettlement.value }; + } + const opErr = opts.opSettlement.reason; + if (isMeshcoreTcpTransportDeadError(opErr)) { + return { action: 'retry' }; + } + return { action: 'throw', error: opErr }; +} diff --git a/src/renderer/lib/protocols/meshcore/MeshCoreTransport.ts b/src/renderer/lib/protocols/meshcore/MeshCoreTransport.ts index 099a324b6..c6ba1c708 100644 --- a/src/renderer/lib/protocols/meshcore/MeshCoreTransport.ts +++ b/src/renderer/lib/protocols/meshcore/MeshCoreTransport.ts @@ -12,7 +12,10 @@ import { withTimeout } from '../../../../shared/withTimeout'; import { isMeshcoreRetryableBleErrorMessage } from '../../bleConnectErrors'; import { connectNobleBleWithScanBusyRetry } from '../../bleReconnectHelper'; import { closeSerialPortIfOpen } from '../../connection'; -import { notifyMeshcoreTcpWriteDead } from '../../meshcore/meshcoreTcpInitBurst'; +import { + isMeshcoreTcpSoftApDeadAccepted, + notifyMeshcoreTcpWriteDead, +} from '../../meshcore/meshcoreTcpInitBurst'; import { patchMeshcoreCompanionTxEchoFilter } from '../../meshcoreCompanionTxEchoFilter'; import { notifyNobleBlePrimaryRfLinkReady } from '../../meshcoreDualNobleBleInit'; import { MeshcoreWebBluetoothConnection } from '../../meshcoreWebBluetoothConnection'; @@ -155,7 +158,12 @@ class IpcTcpConnection { try { await window.electronAPI.meshcore.tcp.write(Array.from(bytes)); } catch (e) { - console.error('[IpcTcpConnection] write error', e); + // SoftAP-accepted dead bridge: expected; keep noise at debug (stats/outbox thrash). + if (isMeshcoreTcpSoftApDeadAccepted()) { + console.debug('[IpcTcpConnection] write on SoftAP dead bridge', e); + } else { + console.error('[IpcTcpConnection] write error', e); + } // Fail closed so meshcore.js stops issuing RPCs after the bridge is gone // (n7eal: peer FIN → no active socket write storm). notifyDisconnectedOnce(this); diff --git a/src/renderer/lib/sessions/meshcoreSession.ts b/src/renderer/lib/sessions/meshcoreSession.ts index a2627b01e..6e0ac8682 100644 --- a/src/renderer/lib/sessions/meshcoreSession.ts +++ b/src/renderer/lib/sessions/meshcoreSession.ts @@ -29,9 +29,14 @@ export interface MeshcoreSessionApi { getDestinationPubKey?: (nodeId: number) => Uint8Array | undefined; /** * SoftAP/OpenHop: when the TCP bridge was accepted dead after contacts FIN, reopen a live - * socket (reconnect) and resolve once getSelfInfo completes — before getContacts / peer FIN. + * socket and resolve once the SoftAP user TX live window is ready (first-RPC path). */ ensureTcpLiveForUserTx?: () => Promise; + /** + * SoftAP/dead-bridge user TX helper: SoftAP parks the op as the first companion RPC on + * quiet reopen; mid-session dead bridge reconnects then runs the op. + */ + runMeshcoreUserTxWithLiveTcp?: (op: () => Promise) => Promise; } let activeSession: MeshcoreSessionApi | null = null; diff --git a/src/renderer/lib/withTimeout.test.ts b/src/renderer/lib/withTimeout.test.ts index 821e3a14e..ec673233c 100644 --- a/src/renderer/lib/withTimeout.test.ts +++ b/src/renderer/lib/withTimeout.test.ts @@ -40,4 +40,30 @@ describe('withTimeout', () => { vi.useRealTimers(); } }); + + it('does not surface late promise reject as unhandled after timeout wins', async () => { + vi.useFakeTimers(); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + try { + let rejectWrite!: (err: Error) => void; + const write = new Promise((_, rej) => { + rejectWrite = rej; + }); + const p = withTimeout(write, 1000, 'setChannel'); + // eslint-disable-next-line @typescript-eslint/no-floating-promises -- fire-and-forget timer advance + vi.advanceTimersByTimeAsync(1000); + await expect(p).rejects.toThrow(/setChannel timed out/); + rejectWrite(new Error('meshcore:tcp-write: no active socket')); + await Promise.resolve(); + await Promise.resolve(); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + vi.useRealTimers(); + } + }); }); diff --git a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts index 8b492b1b4..13956bf75 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts @@ -306,6 +306,19 @@ describe('useMeshcoreRuntime auto-reconnect (regression)', () => { expect(RUNTIME_SOURCE).toContain('ensureTcpLiveForUserTx'); expect(RUNTIME_SOURCE).toContain('notifyMeshcoreTcpLiveForUserTx'); expect(RUNTIME_SOURCE).toContain('yieldToMeshcoreTcpUserTxSends'); + // SoftAP chat send must quiet-reopen via connect() — not connection-lost (disconnect UI). + expect(RUNTIME_SOURCE).toContain('SoftAP user TX — quiet TCP reopen (no connection-lost)'); + expect(RUNTIME_SOURCE).toContain('meshcoreSoftApUserTxReopenInFlightRef'); + expect(RUNTIME_SOURCE).toContain('meshcoreConnectForSoftApTxRef'); + expect(RUNTIME_SOURCE).toMatch( + /useLayoutEffect\(\(\) => \{\s*meshcoreConnectForSoftApTxRef\.current = connect;/, + ); + expect(RUNTIME_SOURCE).toContain('MESHCORE_TCP_SOFTAP_USER_TX_REOPEN_DELAY_MS'); + expect(RUNTIME_SOURCE).toContain( + 'SoftAP user-TX reopen failed — restore SoftAP-accepted configured', + ); + expect(RUNTIME_SOURCE).toContain('SoftAP user-TX reopen — skip contacts dump after live send'); + expect(RUNTIME_SOURCE).toContain('TCP closed on SoftAP-accepted dead bridge — keep configured'); 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\(\)/, ); @@ -370,6 +383,60 @@ describe('useMeshcoreRuntime auto-reconnect (regression)', () => { expect(RUNTIME_SOURCE).toMatch( /takeMeshcoreDiscoverSelfCache\(conn\)[\s\S]*?conn\.getSelfInfo\(5000\)/, ); + // SoftAP user-TX reopen: first companion RPC is the parked user command (skip getSelfInfo). + expect(RUNTIME_SOURCE).toContain('SoftAP user-TX reopen — first-RPC path (skip getSelfInfo)'); + expect(RUNTIME_SOURCE).toContain('runMeshcoreSoftApPendingUserTx'); + expect(RUNTIME_SOURCE).toContain('skipDiscoverSelf: meshcoreSoftApUserTxReopenInFlightRef'); + expect(RUNTIME_SOURCE).not.toContain('SoftAP user-TX fresh'); + expect(RUNTIME_SOURCE).not.toContain('SoftAP user-TX reopen — bridge dead before live window'); + // Late SoftAP post-TX getChannels latched write-dead after Ok and skipped SoftAP retry. + expect(RUNTIME_SOURCE).not.toContain('SoftAP post-TX getChannels'); + expect(RUNTIME_SOURCE).toContain('throwIfMeshcoreTcpBridgeDiedDuringSoftApOp'); + expect(RUNTIME_SOURCE).toContain('setMeshcoreSoftApPendingUserTx'); + expect(RUNTIME_SOURCE).toContain('runMeshcoreUserTxWithLiveTcp'); + expect(RUNTIME_SOURCE).toMatch( + /runMeshcoreUserTxWithLiveTcp\(async \(\) => \{[\s\S]*?sendChannelTextMessage/, + ); + // SoftAP retry must re-latch accepted so attempt 2 stays on quiet reopen. + expect(RUNTIME_SOURCE).toContain('setMeshcoreTcpSoftApDeadAccepted(true)'); + // Late latch after Ok: return fulfilled parked result — do not re-park (double-send). + expect(RUNTIME_SOURCE).toContain('decideSoftApUserTxAfterEnsureFailure'); + expect(RUNTIME_SOURCE).toContain('settleSoftApPendingResult'); + expect(RUNTIME_SOURCE).toMatch( + /decideSoftApUserTxAfterEnsureFailure\([\s\S]*?decision\.action === 'return'[\s\S]*?return decision\.value/, + ); + const softApFirstRpcIdx = RUNTIME_SOURCE.indexOf( + 'SoftAP user-TX reopen — first-RPC path (skip getSelfInfo)', + ); + expect(softApFirstRpcIdx).toBeGreaterThan(-1); + const pendingIdx = RUNTIME_SOURCE.indexOf( + 'runMeshcoreSoftApPendingUserTx()', + softApFirstRpcIdx, + ); + const latchIdx = RUNTIME_SOURCE.indexOf( + 'throwIfMeshcoreTcpBridgeDiedDuringSoftApOp', + softApFirstRpcIdx, + ); + const notifyIdx = RUNTIME_SOURCE.indexOf('notifyMeshcoreTcpLiveForUserTx()', softApFirstRpcIdx); + const softApAcceptIdx = RUNTIME_SOURCE.indexOf( + 'setMeshcoreTcpSoftApDeadAccepted(true)', + softApFirstRpcIdx, + ); + const disconnectIdx = RUNTIME_SOURCE.indexOf('meshcore.tcp.disconnect()', softApFirstRpcIdx); + expect(pendingIdx).toBeGreaterThan(softApFirstRpcIdx); + expect(latchIdx).toBeGreaterThan(pendingIdx); + expect(notifyIdx).toBeGreaterThan(latchIdx); + // SoftAP-accept + configured before intentional disconnect (quiet teardown). + expect(softApAcceptIdx).toBeGreaterThan(notifyIdx); + expect(disconnectIdx).toBeGreaterThan(softApAcceptIdx); + expect(RUNTIME_SOURCE).toContain('TCP closed during SoftAP user-TX reopen — keep configured'); + // SoftAP first-RPC must not dip status to connected (header flicker). + const softApBlock = RUNTIME_SOURCE.slice( + softApFirstRpcIdx, + RUNTIME_SOURCE.indexOf('// Show persisted contacts immediately', softApFirstRpcIdx), + ); + expect(softApBlock).not.toMatch(/status:\s*'connected'/); + expect(softApBlock).toMatch(/status:\s*'configured'/); }); }); diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 9d4070a4c..09f8bf14d 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -168,13 +168,22 @@ import { } from '../lib/meshcore/meshcorePubKeyRegistry'; import { attachMeshcoreSerialTransportLossWatch } from '../lib/meshcore/meshcoreSerialTransportLoss'; import { + clearMeshcoreSoftApPendingUserTx, + decideSoftApUserTxAfterEnsureFailure, isMeshcoreTcpBurstDeadBridge, isMeshcoreTcpSoftApDeadAccepted, + MESHCORE_TCP_SOFTAP_USER_TX_REOPEN_DELAY_MS, notifyMeshcoreTcpLiveForUserTx, rejectMeshcoreTcpLiveForUserTx, + runMeshcoreSoftApPendingUserTx, + runWithMeshcoreTcpDeadWriteRetry, + setMeshcoreSoftApPendingUserTx, setMeshcoreTcpSoftApDeadAccepted, setMeshcoreTcpWriteDeadListener, + settleSoftApPendingResult, shouldDeferMeshcoreTcpReconnectAfterBurst, + throwIfMeshcoreTcpBridgeDiedDuringSoftApOp, + trackMeshcoreTcpUserTxSend, waitForMeshcoreTcpLiveForUserTx, yieldToMeshcoreTcpUserTxSends, } from '../lib/meshcore/meshcoreTcpInitBurst'; @@ -622,6 +631,11 @@ export function useMeshcoreRuntime() { * latch bridge-dead without handleMeshcoreConnectionLost (session already configured). */ const meshcoreTcpContactsDumpInFlightRef = useRef(false); + /** + * SoftAP/OpenHop user TX: true while `ensureTcpLiveForUserTx` has started a quiet `connect()` + * reopen (not handleMeshcoreConnectionLost). Concurrent sends await the same live window. + */ + const meshcoreSoftApUserTxReopenInFlightRef = 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. @@ -760,8 +774,14 @@ export function useMeshcoreRuntime() { /** Fetch and update local radio stats (core, radio, packet). Called by requestRefresh and on connect. */ const fetchAndUpdateLocalStats = useCallback(async () => { + // SoftAP/OpenHop accepted dead bridge — companion RPCs only reopen on user TX. + if (isMeshcoreTcpSoftApDeadAccepted()) return; const conn = connRef.current; if (!conn) return; + // SoftAP/OpenHop: peer FIN left a dead bridge — stats RPCs only spam tcp-write errors. + if (isMeshcoreTcpSoftApDeadAccepted() || meshcoreTcpBridgeDeadRef.current) { + return; + } let coreStats: Awaited>; try { coreStats = await conn.getStatsCore(); @@ -976,6 +996,7 @@ export function useMeshcoreRuntime() { meshcoreStatsPollRef.current = setInterval(() => { if (!meshcoreHookMountedRef.current) return; if (meshcoreInitConnInFlightRef.current) return; + if (isMeshcoreTcpSoftApDeadAccepted()) return; void fetchAndUpdateLocalStats().catch((e: unknown) => { console.warn('[useMeshcoreRuntime] periodic stats poll failed ' + errLikeToLogString(e)); }); @@ -2175,6 +2196,94 @@ export function useMeshcoreRuntime() { })(); } + // SoftAP user-TX reopen: skip getSelfInfo / contacts — run parked user command as the + // first companion RPC (peer FINs ~160ms after self-info; notify→setChannel always loses). + const softApUserTxReopen = meshcoreSoftApUserTxReopenInFlightRef.current; + if (softApUserTxReopen) { + console.debug( + '[useMeshcoreRuntime] SoftAP user-TX reopen — first-RPC path (skip getSelfInfo)', + ); + const transportType = meshcoreConnectTypeRef.current; + const myNodeId = myNodeNumRef.current; + const priorSelf = selfInfoRef.current; + // Stay configured for the whole SoftAP reopen (no connected→configured header flicker). + setState((prev) => ({ + ...prev, + myNodeNum: myNodeId || prev.myNodeNum, + status: 'configured', + connectionLoss: false, + serialNeedsReselect: false, + })); + meshcoreDeviceConfiguredRef.current = true; + meshcoreEverConfiguredRef.current = true; + const identityId = opts?.driverIdentityId ?? meshcoreIdentityIdRef.current; + if (identityId && priorSelf?.publicKey) { + finalizeMeshcoreDriverIdentity(identityId, meshcoreTransportParams(transportType, {}), { + myNodeNum: myNodeId, + publicKey: priorSelf.publicKey, + }); + meshcoreIdentityIdRef.current = identityId; + setMeshcoreIdentityId(identityId); + setConnection(identityId, { + status: 'configured', + connectionType: transportType === 'tcp' ? 'http' : transportType, + myNodeNum: myNodeId, + }); + } + const promoteConfiguredSoftAp = (): void => { + setState((prev) => ({ + ...prev, + myNodeNum: myNodeId || prev.myNodeNum, + 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, + }); + } + }; + try { + const bridgeDeadBefore = meshcoreTcpBridgeDeadRef.current; + await runMeshcoreSoftApPendingUserTx(); + // meshcore.js may resolve Ok while peer FIN latches write-dead — reject so SoftAP + // retry runs (do not notify live on this path). + throwIfMeshcoreTcpBridgeDiedDuringSoftApOp( + bridgeDeadBefore, + meshcoreTcpBridgeDeadRef.current, + ); + notifyMeshcoreTcpLiveForUserTx(); + } catch (e: unknown) { + const err = + e instanceof Error + ? e + : new Error(errLikeToLogString(e) || 'SoftAP pending user TX failed'); + console.warn( + '[useMeshcoreRuntime] SoftAP user-TX first-RPC failed ' + errLikeToLogString(err), + ); + rejectMeshcoreTcpLiveForUserTx(err); + } + console.debug( + '[useMeshcoreRuntime] SoftAP user-TX reopen — skip contacts dump after live send', + ); + // SoftAP-accept + configured before intentional disconnect (quiet teardown). + meshcoreTcpInitBurstCapturedRef.current = true; + meshcoreTcpBridgeDeadRef.current = true; + setMeshcoreTcpSoftApDeadAccepted(true); + promoteConfiguredSoftAp(); + void window.electronAPI.meshcore.tcp.disconnect().catch((e: unknown) => { + console.debug( + '[useMeshcoreRuntime] SoftAP user-TX reopen tcp.disconnect ' + errLikeToLogString(e), + ); + }); + return; + } + // Show persisted contacts immediately while the radio contact dump runs over BLE. const dbCacheStart = performance.now(); let dbCacheNodeCount = 0; @@ -2298,13 +2407,6 @@ export function useMeshcoreRuntime() { }); } - // SoftAP user TX: release waiters while the socket is still live — companions often - // FIN immediately after getContacts. Await tracked sends before starting the dump. - if (transportType === 'tcp' && !meshcoreTcpBridgeDeadRef.current) { - notifyMeshcoreTcpLiveForUserTx(); - await yieldToMeshcoreTcpUserTxSends(); - } - const promoteConfiguredAfterContactsDump = (): void => { setState((prev) => ({ ...prev, @@ -2324,6 +2426,14 @@ export function useMeshcoreRuntime() { } }; + // SoftAP user TX: release waiters while the socket is still live — companions often + // FIN immediately after getContacts. Await tracked sends before starting the dump. + // (SoftAP user-TX reopen returns earlier via first-RPC path above.) + if (transportType === 'tcp' && !meshcoreTcpBridgeDeadRef.current) { + notifyMeshcoreTcpLiveForUserTx(); + await yieldToMeshcoreTcpUserTxSends(); + } + // 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 => { @@ -2881,13 +2991,24 @@ export function useMeshcoreRuntime() { serialRediscoveryStopRef.current?.(); serialRediscoveryStopRef.current = null; } - setState({ - status: 'connecting', - myNodeNum: 0, - connectionType: type === 'tcp' ? 'http' : type, - connectionLoss: false, - serialNeedsReselect: false, - }); + // SoftAP chat reopen: keep configured UI (do not flash connecting / wipe myNodeNum). + // Full connect/reconnect still uses status=connecting below. + if (meshcoreSoftApUserTxReopenInFlightRef.current && type === 'tcp') { + setState((s) => ({ + ...s, + connectionType: 'http', + connectionLoss: false, + serialNeedsReselect: false, + })); + } else { + setState({ + status: 'connecting', + myNodeNum: 0, + connectionType: type === 'tcp' ? 'http' : type, + connectionLoss: false, + serialNeedsReselect: false, + }); + } meshcoreDeviceConfiguredRef.current = false; if (type === 'ble') bleConnectInProgressRef.current = true; meshcoreExplicitDisconnectRef.current = false; @@ -2959,6 +3080,33 @@ export function useMeshcoreRuntime() { const handleRfConnectFailure = useCallback( (type: 'ble' | 'serial' | 'tcp', driverIdentityId?: string): Promise => { + // SoftAP chat reopen failed mid-handshake: restore accepted dead-bridge session. + // Leaving disconnected here stranded SoftAP TX with no reconnect owner (post-fix logs). + if (type === 'tcp' && meshcoreSoftApUserTxReopenInFlightRef.current) { + meshcoreTcpBridgeDeadRef.current = true; + setMeshcoreTcpSoftApDeadAccepted(true); + meshcoreDeferredReconnectRef.current = false; + meshcoreDeviceConfiguredRef.current = true; + meshcoreEverConfiguredRef.current = true; + const myNodeNum = myNodeNumRef.current; + setState((s) => ({ + ...s, + status: 'configured', + connectionLoss: false, + connectionType: 'http', + myNodeNum: myNodeNum || s.myNodeNum, + })); + console.debug( + '[useMeshcoreRuntime] SoftAP user-TX reopen failed — restore SoftAP-accepted configured', + ); + clearMeshcoreSoftApPendingUserTx(new Error('MeshCore SoftAP user-TX reopen failed')); + teardownMeshcoreConnEventListeners({ + driverDisconnect: true, + driverIdentityId, + }); + connRef.current = null; + return Promise.resolve(); + } setState({ status: 'disconnected', myNodeNum: 0, connectionType: null }); meshcoreDeviceConfiguredRef.current = false; // Keep sticky suppress across failed BLE open so NodeDB cannot revive Blue. @@ -3531,26 +3679,117 @@ export function useMeshcoreRuntime() { handleMeshcoreConnectionLostRef.current = handleMeshcoreConnectionLost; + /** Set after `connect` is defined — SoftAP user TX reopen must not use connection-lost. */ + const meshcoreConnectForSoftApTxRef = useRef< + | (( + type: 'ble' | 'serial' | 'tcp', + tcpHost?: string, + blePeripheralId?: string, + ) => Promise) + | null + >(null); + const ensureTcpLiveForUserTx = useCallback(async (): Promise => { const bridgeDead = meshcoreTcpBridgeDeadRef.current; const softAp = isMeshcoreTcpSoftApDeadAccepted(); if (!softAp && !bridgeDead) { return; } - // Already opening — just wait for the post-getSelfInfo gate. + // Already opening — wait for the post-getSelfInfo live window (SoftAP quiet reopen or + // reconnect). SoftAP reopen clears bridgeDead before connect settles; do not require live. if ( meshcoreConnectTypeRef.current === 'tcp' && - (meshcoreInitConnInFlightRef.current || meshcoreIsReconnectingRef.current) && - !meshcoreTcpBridgeDeadRef.current + (meshcoreInitConnInFlightRef.current || + meshcoreIsReconnectingRef.current || + meshcoreSoftApUserTxReopenInFlightRef.current) ) { await waitForMeshcoreTcpLiveForUserTx(); return; } + // SoftAP-accepted dead bridge: reopen via connect() — not handleMeshcoreConnectionLost. + // Connection-lost sets connectionLoss + 2s backoff and looks like a drop on every chat send. + if (softAp) { + const host = meshcoreConnectionParamsRef.current?.httpAddress?.trim(); + const connectFn = meshcoreConnectForSoftApTxRef.current; + if (!host || !connectFn) { + throw new Error('MeshCore SoftAP user-TX reopen missing TCP host or connect'); + } + // Keep SoftAP latch during settle so background writes stay suppressed. Immediate + // reconnect FINs in <200ms (post-fix); match reconnect attempt-1 backoff. + meshcoreSoftApUserTxReopenInFlightRef.current = true; + console.debug( + `[useMeshcoreRuntime] SoftAP user TX — settle ${MESHCORE_TCP_SOFTAP_USER_TX_REOPEN_DELAY_MS}ms before quiet reopen`, + ); + await new Promise((resolve) => { + setTimeout(resolve, MESHCORE_TCP_SOFTAP_USER_TX_REOPEN_DELAY_MS); + }); + if (meshcoreExplicitDisconnectRef.current) { + meshcoreSoftApUserTxReopenInFlightRef.current = false; + throw new Error('MeshCore SoftAP user-TX reopen aborted (user disconnect)'); + } + setMeshcoreTcpSoftApDeadAccepted(false); + meshcoreTcpBridgeDeadRef.current = false; + console.debug('[useMeshcoreRuntime] SoftAP user TX — quiet TCP reopen (no connection-lost)'); + void connectFn('tcp', host) + .catch((e: unknown) => { + console.warn( + '[useMeshcoreRuntime] SoftAP user-TX reopen failed ' + errLikeToLogString(e), + ); + rejectMeshcoreTcpLiveForUserTx( + e instanceof Error ? e : new Error(errLikeToLogString(e) || 'SoftAP reopen failed'), + ); + }) + .finally(() => { + meshcoreSoftApUserTxReopenInFlightRef.current = false; + }); + await waitForMeshcoreTcpLiveForUserTx(); + return; + } + // Mid-session dead bridge (not SoftAP-accepted): normal reconnect recovery. setMeshcoreTcpSoftApDeadAccepted(false); handleMeshcoreConnectionLostRef.current(); await waitForMeshcoreTcpLiveForUserTx(); }, []); + /** SoftAP / dead-bridge user TX: park op for SoftAP first-RPC reopen; retry once on dead write. */ + const runMeshcoreUserTxWithLiveTcp = useCallback( + async (op: () => Promise): Promise => { + const softApIntent = isMeshcoreTcpSoftApDeadAccepted(); + if (!softApIntent && !meshcoreTcpBridgeDeadRef.current) { + return op(); + } + // Mid-session dead bridge (not SoftAP): reconnect then run op after live window. + if (!softApIntent) { + return runWithMeshcoreTcpDeadWriteRetry(ensureTcpLiveForUserTx, op); + } + + let lastErr: unknown; + for (let attempt = 0; attempt < 2; attempt++) { + // SoftAP quiet reopen must stay SoftAP across retries (clearing accepted mid-open + // sent attempt 2 into handleMeshcoreConnectionLost + discoverSelf reuse). + setMeshcoreTcpSoftApDeadAccepted(true); + const resultPromise = setMeshcoreSoftApPendingUserTx(op); + try { + await ensureTcpLiveForUserTx(); + return await resultPromise; + } catch (e: unknown) { + clearMeshcoreSoftApPendingUserTx( + e instanceof Error ? e : new Error(errLikeToLogString(e) || 'SoftAP TX failed'), + ); + // Late write-dead latch after meshcore.js Ok: resultPromise is already fulfilled — + // return that value. Re-parking would double-send chat. + const opSettlement = await settleSoftApPendingResult(resultPromise); + const decision = decideSoftApUserTxAfterEnsureFailure({ opSettlement }); + if (decision.action === 'return') return decision.value; + if (decision.action === 'throw') throw decision.error; + lastErr = opSettlement.status === 'rejected' ? opSettlement.reason : e; + continue; + } + } + throw lastErr; + }, + [ensureTcpLiveForUserTx], + ); const onPowerSuspend = useCallback(() => { meshcoreReconnectGenerationRef.current += 1; meshcoreIsReconnectingRef.current = false; @@ -3624,6 +3863,7 @@ export function useMeshcoreRuntime() { openMeshCoreTransport(type, { blePeripheralId, host: type === 'tcp' ? (tcpHost ?? 'localhost') : undefined, + skipDiscoverSelf: meshcoreSoftApUserTxReopenInFlightRef.current, }); opened = type === 'ble' && isRendererNobleBlePlatform() @@ -3767,6 +4007,9 @@ export function useMeshcoreRuntime() { }, [prepareRfConnect, attachRfSession, handleRfConnectFailure], ); + useLayoutEffect(() => { + meshcoreConnectForSoftApTxRef.current = connect; + }, [connect]); /** * Gesture-free reconnect — called on startup when a last connection is remembered. @@ -4066,9 +4309,23 @@ export function useMeshcoreRuntime() { ? replyId : undefined; try { - const channelConn = connRef.current; - if (channelConn) { - await channelConn.sendChannelTextMessage(channelIdx, textToSend); + const hadRadioConn = + connRef.current != null || + isMeshcoreTcpSoftApDeadAccepted() || + meshcoreTcpBridgeDeadRef.current; + if (hadRadioConn) { + await runMeshcoreUserTxWithLiveTcp(async () => { + const liveConn = connRef.current; + if (!liveConn) throw new Error('Not connected to radio'); + const work = liveConn.sendChannelTextMessage(channelIdx, textToSend); + if ( + isMeshcoreTcpSoftApDeadAccepted() || + meshcoreSoftApUserTxReopenInFlightRef.current + ) { + trackMeshcoreTcpUserTxSend(work); + } + await work; + }); markMeshcoreCompanionTx(); void fetchAndUpdateLocalStats().catch((e: unknown) => { console.warn( @@ -4130,7 +4387,13 @@ export function useMeshcoreRuntime() { } } }, - [addMessage, readMeshcoreMessages, selfInfo, fetchAndUpdateLocalStats], + [ + addMessage, + readMeshcoreMessages, + selfInfo, + fetchAndUpdateLocalStats, + runMeshcoreUserTxWithLiveTcp, + ], ); const refreshContacts = useCallback(async () => { @@ -6467,65 +6730,88 @@ export function useMeshcoreRuntime() { } }, []); - const setMeshcoreChannel = useCallback(async (idx: number, name: string, secret: Uint8Array) => { - if (!connRef.current) { - console.warn('[useMeshcoreRuntime] setMeshcoreChannel: no connection'); - return; - } - - // Validate parameters - if (!Number.isInteger(idx) || idx < 0 || idx > 39) { - console.warn('[useMeshcoreRuntime] setMeshcoreChannel: invalid channel index', idx); - throw new Error(`Invalid channel index: ${idx}. Must be 0-39.`); - } + const setMeshcoreChannel = useCallback( + async (idx: number, name: string, secret: Uint8Array) => { + // Validate parameters before SoftAP reopen (avoid pointless TCP churn). + if (!Number.isInteger(idx) || idx < 0 || idx > 39) { + console.warn('[useMeshcoreRuntime] setMeshcoreChannel: invalid channel index', idx); + throw new Error(`Invalid channel index: ${idx}. Must be 0-39.`); + } - if (typeof name !== 'string' || name.length === 0) { - console.warn('[useMeshcoreRuntime] setMeshcoreChannel: invalid name', name); - throw new Error('Channel name must be a non-empty string'); - } + if (typeof name !== 'string' || name.length === 0) { + console.warn('[useMeshcoreRuntime] setMeshcoreChannel: invalid name', name); + throw new Error('Channel name must be a non-empty string'); + } - if (name.length > MESHCORE_CHANNEL_NAME_MAX_LEN) { - console.warn('[useMeshcoreRuntime] setMeshcoreChannel: name too long', name.length); - throw new Error(`Channel name must be at most ${MESHCORE_CHANNEL_NAME_MAX_LEN} characters`); - } + if (name.length > MESHCORE_CHANNEL_NAME_MAX_LEN) { + console.warn('[useMeshcoreRuntime] setMeshcoreChannel: name too long', name.length); + throw new Error(`Channel name must be at most ${MESHCORE_CHANNEL_NAME_MAX_LEN} characters`); + } - if (!(secret instanceof Uint8Array) || secret.length === 0) { - console.warn( - '[useMeshcoreRuntime] setMeshcoreChannel: invalid secret ' + errLikeToLogString(secret), - ); - throw new Error('Channel secret must be a non-empty Uint8Array'); - } + if (!(secret instanceof Uint8Array) || secret.length === 0) { + console.warn( + `[useMeshcoreRuntime] setMeshcoreChannel: invalid secret length=${ + secret instanceof Uint8Array ? secret.length : 'n/a' + }`, + ); + throw new Error('Channel secret must be a non-empty Uint8Array'); + } - try { - await withTimeout(connRef.current.setChannel(idx, name, secret), 10_000, 'setChannel'); - setChannels((prev) => { - const next = prev.filter((c) => c.index !== idx); - return [...next, { index: idx, name, secret }].sort((a, b) => a.index - b.index); - }); - } catch (e) { - const error = normalizeMeshCoreError(e, 'Failed to save channel to device'); - console.warn( - `[useMeshcoreRuntime] setMeshcoreChannel error ${formatStructuredLogDetail({ - errorMessage: error.message, - errorType: typeof e, - idx, - name, - secretLength: secret?.length, - })}`, - ); - throw error; - } - }, []); + try { + await runMeshcoreUserTxWithLiveTcp(async () => { + const liveConn = connRef.current; + if (!liveConn) { + console.warn('[useMeshcoreRuntime] setMeshcoreChannel: no connection'); + throw new Error('Not connected to radio'); + } + const work = withTimeout(liveConn.setChannel(idx, name, secret), 10_000, 'setChannel'); + if (isMeshcoreTcpSoftApDeadAccepted() || meshcoreSoftApUserTxReopenInFlightRef.current) { + trackMeshcoreTcpUserTxSend(work); + } + await work; + }); + setChannels((prev) => { + const next = prev.filter((c) => c.index !== idx); + return [...next, { index: idx, name, secret }].sort((a, b) => a.index - b.index); + }); + } catch (e) { + const error = normalizeMeshCoreError(e, 'Failed to save channel to device'); + console.warn( + `[useMeshcoreRuntime] setMeshcoreChannel error ${formatStructuredLogDetail({ + errorMessage: error.message, + errorType: typeof e, + idx, + name, + secretLength: secret?.length, + })}`, + ); + throw error; + } + }, + [runMeshcoreUserTxWithLiveTcp], + ); - const deleteMeshcoreChannel = useCallback(async (idx: number) => { - if (!connRef.current) return; - try { - await connRef.current.deleteChannel(idx); - setChannels((prev) => prev.filter((c) => c.index !== idx)); - } catch (e) { - console.warn('[useMeshcoreRuntime] deleteMeshcoreChannel error ' + errLikeToLogString(e)); - } - }, []); + const deleteMeshcoreChannel = useCallback( + async (idx: number) => { + try { + await runMeshcoreUserTxWithLiveTcp(async () => { + const liveConn = connRef.current; + if (!liveConn) { + throw new Error('Not connected to radio'); + } + const work = liveConn.deleteChannel(idx); + if (isMeshcoreTcpSoftApDeadAccepted() || meshcoreSoftApUserTxReopenInFlightRef.current) { + trackMeshcoreTcpUserTxSend(work); + } + await work; + }); + setChannels((prev) => prev.filter((c) => c.index !== idx)); + } catch (e) { + console.warn('[useMeshcoreRuntime] deleteMeshcoreChannel error ' + errLikeToLogString(e)); + } + }, + [runMeshcoreUserTxWithLiveTcp], + ); const importContacts = useCallback(async (): Promise<{ imported: number; @@ -6778,7 +7064,11 @@ export function useMeshcoreRuntime() { const sendReaction = useCallback( async (glyph: string, replyId: number, channel: number) => { - if (!connRef.current) { + if ( + !connRef.current && + !isMeshcoreTcpSoftApDeadAccepted() && + !meshcoreTcpBridgeDeadRef.current + ) { throw new Error('Not connected to radio'); } const parsed = reactionGlyphFromPicker(glyph); @@ -6800,7 +7090,6 @@ export function useMeshcoreRuntime() { : null; const tapbackText = openReactionWire ?? buildMeshcoreOutboundTapbackWire(targetName, parsed.glyph); - const conn = connRef.current; const me = myNodeNumRef.current; const publishTapback = (tapbackMsg: ChatMessage) => { @@ -6816,8 +7105,15 @@ export function useMeshcoreRuntime() { 'Cannot send reaction: no encryption key for this contact. Wait for a full contact exchange, refresh contacts, or remove name-only stubs.', ); } - // Tapbacks are fire-and-forget; no ACK tracking or status UI for reactions - await conn.sendTextMessage(pubKey, tapbackText); + await runMeshcoreUserTxWithLiveTcp(async () => { + const liveConn = connRef.current; + if (!liveConn) throw new Error('Not connected to radio'); + const work = liveConn.sendTextMessage(pubKey, tapbackText); + if (isMeshcoreTcpSoftApDeadAccepted() || meshcoreSoftApUserTxReopenInFlightRef.current) { + trackMeshcoreTcpUserTxSend(work); + } + await work; + }); markMeshcoreCompanionTx(); const tapbackTs = Date.now(); const tapbackMsg: ChatMessage = { @@ -6839,8 +7135,15 @@ export function useMeshcoreRuntime() { : channel === -1 ? 0 : channel; - // Tapbacks are fire-and-forget; no ACK tracking or status UI for reactions - await conn.sendChannelTextMessage(outboundChannel, tapbackText); + await runMeshcoreUserTxWithLiveTcp(async () => { + const liveConn = connRef.current; + if (!liveConn) throw new Error('Not connected to radio'); + const work = liveConn.sendChannelTextMessage(outboundChannel, tapbackText); + if (isMeshcoreTcpSoftApDeadAccepted() || meshcoreSoftApUserTxReopenInFlightRef.current) { + trackMeshcoreTcpUserTxSend(work); + } + await work; + }); markMeshcoreCompanionTx(); publishTapback({ sender_id: me, @@ -6854,7 +7157,7 @@ export function useMeshcoreRuntime() { }); } }, - [addMessage, readMeshcoreMessages, selfInfo?.name], + [addMessage, readMeshcoreMessages, runMeshcoreUserTxWithLiveTcp, selfInfo?.name], ); // ─── MeshCore Device Time ──────────────────────────────────────── @@ -7480,9 +7783,11 @@ export function useMeshcoreRuntime() { deviceConfigured: meshcoreDeviceConfiguredRef.current, initConnInFlight: meshcoreInitConnInFlightRef.current, }); - // SoftAP-accepted dead bridge: flood advert / outbox write-fail must not reconnect-loop. + // SoftAP-accepted dead bridge: flood advert / outbox / intentional SoftAP reopen + // tcp.disconnect must not reconnect-loop (ipc or write). SoftAP user-TX reopen-in-flight + // must also stay quiet (accepted cleared mid-open; FIN must not flash connectionLoss). const softApAccepted = isMeshcoreTcpSoftApDeadAccepted(); - const suppressWriteLost = softApAccepted && source === 'write'; + const softApUserTxReopen = meshcoreSoftApUserTxReopenInFlightRef.current; if (defer) { meshcoreDeferredReconnectRef.current = true; // Latch SoftAP-accepted as soon as configured+deferred so flood advert cannot @@ -7497,9 +7802,18 @@ export function useMeshcoreRuntime() { ); return; } - if (suppressWriteLost) { + if (softApAccepted || softApUserTxReopen) { + if (softApUserTxReopen) { + setMeshcoreTcpSoftApDeadAccepted(true); + } console.debug( - '[useMeshcoreRuntime] TCP write-dead on SoftAP-accepted dead bridge — keep configured', + softApUserTxReopen + ? source === 'write' + ? '[useMeshcoreRuntime] TCP write-dead during SoftAP user-TX reopen — keep configured' + : '[useMeshcoreRuntime] TCP closed during SoftAP user-TX reopen — keep configured' + : source === 'write' + ? '[useMeshcoreRuntime] TCP write-dead on SoftAP-accepted dead bridge — keep configured' + : '[useMeshcoreRuntime] TCP closed on SoftAP-accepted dead bridge — keep configured', ); return; } @@ -7530,6 +7844,7 @@ export function useMeshcoreRuntime() { connectAutomatic, getDestinationPubKey: (nodeId) => pubKeyMapRef.current.get(nodeId), ensureTcpLiveForUserTx, + runMeshcoreUserTxWithLiveTcp, }); return () => registerMeshcoreSession(null); }, [ @@ -7540,6 +7855,7 @@ export function useMeshcoreRuntime() { finalizeDriverDisconnect, connectAutomatic, ensureTcpLiveForUserTx, + runMeshcoreUserTxWithLiveTcp, ]); return useMemo( diff --git a/src/shared/withTimeout.ts b/src/shared/withTimeout.ts index 8d4fb91fd..fa83e61f9 100644 --- a/src/shared/withTimeout.ts +++ b/src/shared/withTimeout.ts @@ -6,6 +6,16 @@ export function withTimeout(promise: Promise, ms: number, label: string): reject(new Error(`${label} timed out after ${ms}ms`)); }, ms); }); + // Swallow late rejects from the loser of the race so they cannot surface as + // Unhandled rejection (SoftAP: tcp-write fails after timeout already won). + void promise.then( + () => undefined, + () => undefined, + ); + void timeoutPromise.then( + () => undefined, + () => undefined, + ); return Promise.race([ promise.finally(() => { if (timeoutId !== undefined) clearTimeout(timeoutId);