Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/hooks/openMeshCoreTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@ 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<OpenMeshCoreTransportResult> {
const params = meshcoreTransportParams(type, {
peripheralId: opts.blePeripheralId,
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) => {
Expand Down
92 changes: 71 additions & 21 deletions src/renderer/hooks/useSendMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!handle) {
console.warn('[useSendMessage] no handle for', identityId);
return;
}

const finishSend = (
sendHandle: NonNullable<typeof handle>,
opts?: { trackForSoftApLiveWindow?: boolean },
Expand Down Expand Up @@ -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],
Expand Down
9 changes: 7 additions & 2 deletions src/renderer/lib/drivers/ConnectionDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,11 @@ export class ConnectionDriver {
this.registerTransportKeys(identityId, provisionalKey, resolvedKey);
}

async connect(protocolType: string, params: TransportParams): Promise<IdentityId> {
async connect(
protocolType: string,
params: TransportParams,
opts?: { skipDiscoverSelf?: boolean },
): Promise<IdentityId> {
const protocol = getProtocolForType(protocolType);
if (!protocol) throw new Error(`Unknown protocol: ${protocolType}`);

Expand Down Expand Up @@ -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) {
Expand Down
189 changes: 189 additions & 0 deletions src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading