Skip to content

Commit 065277a

Browse files
Yerazeclaude
andcommitted
fix(meshcore): full official-app DM retry cadence + single-bubble fix (#3977)
Extends the DM ack-timeout retry from a minimal one-shot flood to the full official MeshCore app cadence (MeshCore FAQ §5.3), and fixes the duplicate- message bug in the previous phase. Cadence (named constants): - DM_SAME_PATH_RETRIES = 2: on ack timeout, resend on the CURRENT cached path. - DM_FLOOD_RETRIES = 1: after same-path retries exhaust, reset the path and resend via flood with the default scope; the ACK teaches the new route, which the firmware auto-persists via the existing contact_path_updated push. - After all retries exhaust, mark the message failed. These match the documented firmware/app default ("fail after 3 retries, reset path and flood on the last retry"): 2 same-path + 1 flood = 3 retries, ~4 transmissions incl. the initial send. Each attempt waits the firmware's own estTimeout (+20% margin). Single-bubble fix (previous phase created a duplicate): - An auto-retry resend no longer creates a new message row or re-emits a `message` event (that produced a second bubble with contradictory failed/delivered status and a duplicate DB row). The retry reuses the ORIGINAL message; performScopedSend skips addMessage/emit when isAutoRetry. - handleDmAckTimeout re-points the message's tracked expectedAckCrc/estTimeout to each new attempt via a new `meshcore:message:updated` event, so the UI keeps ONE bubble that ends `delivered` on any ACK or `failed` only after all attempts are exhausted. - Frontend (useMeshCore) handles the event: cancels the prior attempt's fail timer, updates the message in place, and re-arms against the new CRC. Concurrency: the reset+resend runs inside runSerialized (per-source lock, no re-entrancy — the timer fires outside the lock). Timers are keyed by the current attempt's CRC, cleared on ack and in bulk on disconnect; a torn-down manager cannot fire a stray retry mid-sequence. Tests: meshcoreManager.dmRetry.test.ts rewritten (10 cases) — same-path retry sequence, flood-after-same-path-exhausted, single-bubble/no-duplicate-emit, delivered-cancels-sequence, exhaustion→failed, reset_path-failure, path persistence on flood ack, channel-send excluded, disconnect cleanup mid-seq. Full suite green (8068 passed, 0 failed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015AFBA76hsjqhsXe1BdnYub
1 parent 5c8be4d commit 065277a

4 files changed

Lines changed: 423 additions & 89 deletions

File tree

src/components/MeshCore/hooks/useMeshCore.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,50 @@ export function useMeshCore(options: UseMeshCoreOptions): UseMeshCoreState {
590590
));
591591
};
592592

593+
// DM ack-timeout retry update (#3977): the server re-sent a DM on a new
594+
// path (same-path resend, or flood after reset) and re-points this message
595+
// to the new attempt's ack CRC, or marks it failed once all retries are
596+
// exhausted. Keep a SINGLE bubble — update the existing message in place
597+
// rather than appending a new one, and follow the fail timer to the new CRC.
598+
const onMessageUpdated = (evt: {
599+
sourceId: string;
600+
id: string;
601+
previousAckCrc?: number;
602+
expectedAckCrc?: number;
603+
estTimeout?: number;
604+
deliveryStatus?: MessageDeliveryStatus;
605+
}) => {
606+
if (evt.sourceId !== sourceId) return;
607+
// Cancel the fail timer armed for the prior attempt's CRC — the new
608+
// attempt (or terminal status) supersedes it.
609+
if (evt.previousAckCrc != null) {
610+
const prevTimer = ackTimers.get(evt.previousAckCrc);
611+
if (prevTimer) {
612+
clearTimeout(prevTimer);
613+
ackTimers.delete(evt.previousAckCrc);
614+
}
615+
}
616+
setMessages(prev => prev.map(m => {
617+
if (m.id !== evt.id) return m;
618+
return {
619+
...m,
620+
...(evt.expectedAckCrc != null ? { expectedAckCrc: evt.expectedAckCrc } : {}),
621+
...(evt.estTimeout != null ? { estTimeout: evt.estTimeout } : {}),
622+
...(evt.deliveryStatus ? { deliveryStatus: evt.deliveryStatus } : {}),
623+
};
624+
}));
625+
// Arm a fresh fail timer for the new attempt unless this was a terminal
626+
// status (delivered/failed).
627+
if (
628+
evt.expectedAckCrc != null &&
629+
evt.estTimeout != null &&
630+
evt.deliveryStatus !== 'failed' &&
631+
evt.deliveryStatus !== 'delivered'
632+
) {
633+
startAckTimeout(evt.expectedAckCrc, evt.estTimeout);
634+
}
635+
};
636+
593637
// Channel "heard repeaters" (#3700): a repeater re-flooded one of our
594638
// outgoing channel messages and the server correlated the self-echo. The
595639
// event carries the current full heard-by set, so we replace state by id.
@@ -609,6 +653,7 @@ export function useMeshCore(options: UseMeshCoreOptions): UseMeshCoreState {
609653
socket.on('meshcore:status:updated', onStatusUpdated);
610654
socket.on('meshcore:local-node:updated', onLocalNodeUpdated);
611655
socket.on('meshcore:send-confirmed', onSendConfirmed);
656+
socket.on('meshcore:message:updated', onMessageUpdated);
612657
socket.on('meshcore:channel-heard', onChannelHeard);
613658
socket.io.on('reconnect', onReconnect);
614659

@@ -621,6 +666,7 @@ export function useMeshCore(options: UseMeshCoreOptions): UseMeshCoreState {
621666
socket.off('meshcore:status:updated', onStatusUpdated);
622667
socket.off('meshcore:local-node:updated', onLocalNodeUpdated);
623668
socket.off('meshcore:send-confirmed', onSendConfirmed);
669+
socket.off('meshcore:message:updated', onMessageUpdated);
624670
socket.off('meshcore:channel-heard', onChannelHeard);
625671
socket.io.off('reconnect', onReconnect);
626672
};

src/server/meshcoreManager.dmRetry.test.ts

Lines changed: 143 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
/**
2-
* Tests for MeshCore DM auto-retry-via-flood on ack timeout (#3977).
2+
* Tests for MeshCore DM auto-retry on ack timeout (#3977).
33
*
4-
* A DM sent on a stale cached path just goes out once today and is never
5-
* retried, so it silently never arrives. This automates exactly what the
6-
* existing "Reset Path" button already does by hand: if no `SendConfirmed`
7-
* ack arrives within the firmware's own `estTimeout` (+margin), reset the
8-
* contact's cached path and resend once via flood. The retry's own send is
9-
* not itself tracked — a second miss is left to the frontend's existing
10-
* per-message ack timer, exactly as any other unacked send today.
4+
* A DM sent on a stale cached path used to go out once and never retry, so it
5+
* silently never arrived. This replicates the official MeshCore app cadence
6+
* (MeshCore FAQ §5.3): on each ack timeout, resend on the CURRENT cached path
7+
* `DM_SAME_PATH_RETRIES` (2) times, then reset the path and resend via flood
8+
* `DM_FLOOD_RETRIES` (1) time, then give up and mark the message failed. The
9+
* whole sequence reuses the ORIGINAL message — it never creates a second bubble
10+
* / DB row; instead it re-points the message's tracked ack CRC via
11+
* `emitMeshCoreMessageUpdated` and settles on `delivered` (any ACK) or `failed`
12+
* (all retries exhausted).
1113
*/
1214
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
1315
import { MeshCoreManager, MeshCoreDeviceType } from './meshcoreManager.js';
1416
import databaseService from '../services/database.js';
17+
import { dataEventEmitter } from './services/dataEventEmitter.js';
1518

1619
interface BridgeCall { cmd: string; params: Record<string, unknown>; }
1720

@@ -30,7 +33,13 @@ function makeManager(opts: {
3033

3134
const bridgeCalls: BridgeCall[] = [];
3235
let sendIdx = 0;
33-
const sends = opts.sends ?? [{ ackCrc: 111, estTimeout: 8000 }, { ackCrc: 222, estTimeout: 8000 }];
36+
// Distinct CRC per attempt so the retry sequence re-points cleanly.
37+
const sends = opts.sends ?? [
38+
{ ackCrc: 111, estTimeout: 8000 },
39+
{ ackCrc: 222, estTimeout: 8000 },
40+
{ ackCrc: 333, estTimeout: 8000 },
41+
{ ackCrc: 444, estTimeout: 8000 },
42+
];
3443

3544
(m as any).sendBridgeCommand = async (cmd: string, params: Record<string, unknown>) => {
3645
bridgeCalls.push({ cmd, params });
@@ -69,6 +78,8 @@ function sendConfirmed(m: MeshCoreManager, ackCode: number): void {
6978
}
7079

7180
const PUBKEY = 'bob'.padEnd(64, '0');
81+
const sends = (calls: BridgeCall[]) => calls.filter(c => c.cmd === 'send_message');
82+
const resets = (calls: BridgeCall[]) => calls.filter(c => c.cmd === 'reset_path');
7283

7384
describe('MeshCoreManager — DM ack-timeout auto-retry (#3977)', () => {
7485
beforeEach(() => {
@@ -88,83 +99,173 @@ describe('MeshCoreManager — DM ack-timeout auto-retry (#3977)', () => {
8899

89100
await vi.advanceTimersByTimeAsync(20_000);
90101

91-
const sendCalls = bridgeCalls.filter(c => c.cmd === 'send_message');
92-
expect(sendCalls).toHaveLength(1);
93-
expect(bridgeCalls.some(c => c.cmd === 'reset_path')).toBe(false);
102+
expect(sends(bridgeCalls)).toHaveLength(1);
103+
expect(resets(bridgeCalls)).toHaveLength(0);
104+
expect((manager as any).pendingDmRetries.size).toBe(0);
94105
});
95106

96-
it('resets the path and resends once via flood when no ack arrives in time', async () => {
107+
it('resends on the CURRENT path (no reset) for the first same-path retries', async () => {
97108
const { manager, bridgeCalls } = makeManager();
98109

99110
await manager.sendMessageWithResult('hello', PUBKEY);
100-
// No send_confirmed dispatched — let the ack timeout (estTimeout=8000, +20% margin) fire.
111+
112+
// First timeout -> same-path retry #1 (no reset).
113+
await vi.advanceTimersByTimeAsync(10_000);
114+
expect(sends(bridgeCalls)).toHaveLength(2);
115+
expect(resets(bridgeCalls)).toHaveLength(0);
116+
expect(sends(bridgeCalls)[1].params.to).toBe(PUBKEY);
117+
118+
// Second timeout -> same-path retry #2 (still no reset).
101119
await vi.advanceTimersByTimeAsync(10_000);
120+
expect(sends(bridgeCalls)).toHaveLength(3);
121+
expect(resets(bridgeCalls)).toHaveLength(0);
122+
});
123+
124+
it('falls back to flood (reset path) only after same-path retries are exhausted', async () => {
125+
const { manager, bridgeCalls } = makeManager();
102126

103-
const resetCalls = bridgeCalls.filter(c => c.cmd === 'reset_path');
104-
expect(resetCalls).toHaveLength(1);
105-
expect(resetCalls[0].params.public_key).toBe(PUBKEY);
127+
await manager.sendMessageWithResult('hello', PUBKEY);
128+
await vi.advanceTimersByTimeAsync(10_000); // same-path retry #1
129+
await vi.advanceTimersByTimeAsync(10_000); // same-path retry #2
130+
expect(resets(bridgeCalls)).toHaveLength(0);
106131

107-
const sendCalls = bridgeCalls.filter(c => c.cmd === 'send_message');
108-
expect(sendCalls).toHaveLength(2);
109-
expect(sendCalls[1].params.text).toBe('hello');
110-
expect(sendCalls[1].params.to).toBe(PUBKEY);
132+
// Third timeout -> path is reset and the message floods.
133+
await vi.advanceTimersByTimeAsync(10_000);
134+
expect(resets(bridgeCalls)).toHaveLength(1);
135+
expect(resets(bridgeCalls)[0].params.public_key).toBe(PUBKEY);
136+
expect(sends(bridgeCalls)).toHaveLength(4);
137+
expect(sends(bridgeCalls)[3].params.to).toBe(PUBKEY);
111138
});
112139

113-
it('gives up silently if the flood retry also goes unacked (no second retry)', async () => {
140+
it('marks the message failed after all same-path + flood retries are exhausted', async () => {
114141
const { manager, bridgeCalls } = makeManager();
142+
const updated = vi.spyOn(dataEventEmitter, 'emitMeshCoreMessageUpdated');
115143

116144
await manager.sendMessageWithResult('hello', PUBKEY);
117-
await vi.advanceTimersByTimeAsync(10_000); // first timeout -> retry fires
118-
await vi.advanceTimersByTimeAsync(10_000); // retry's own timeout -> should NOT retry again
145+
await vi.advanceTimersByTimeAsync(10_000); // same-path #1
146+
await vi.advanceTimersByTimeAsync(10_000); // same-path #2
147+
await vi.advanceTimersByTimeAsync(10_000); // flood
148+
await vi.advanceTimersByTimeAsync(10_000); // exhausted -> failed
149+
150+
// 1 initial + 2 same-path + 1 flood = 4 total, exactly one reset.
151+
expect(sends(bridgeCalls)).toHaveLength(4);
152+
expect(resets(bridgeCalls)).toHaveLength(1);
153+
154+
// A terminal 'failed' status was emitted for the single message.
155+
const failedCalls = updated.mock.calls.filter(c => (c[0] as any).deliveryStatus === 'failed');
156+
expect(failedCalls).toHaveLength(1);
157+
expect((manager as any).pendingDmRetries.size).toBe(0);
119158

120-
expect(bridgeCalls.filter(c => c.cmd === 'reset_path')).toHaveLength(1);
121-
expect(bridgeCalls.filter(c => c.cmd === 'send_message')).toHaveLength(2);
159+
// No further sends after exhaustion.
160+
await vi.advanceTimersByTimeAsync(30_000);
161+
expect(sends(bridgeCalls)).toHaveLength(4);
122162
});
123163

124-
it('does not resend when reset_path itself fails', async () => {
125-
const { manager, bridgeCalls } = makeManager({ resetPathOk: false });
164+
it('keeps a SINGLE bubble: retries update the original message, never emit a new one', async () => {
165+
const { manager } = makeManager();
166+
const newMessage = vi.spyOn(dataEventEmitter, 'emitMeshCoreMessage');
167+
const updated = vi.spyOn(dataEventEmitter, 'emitMeshCoreMessageUpdated');
126168

127169
await manager.sendMessageWithResult('hello', PUBKEY);
128-
await vi.advanceTimersByTimeAsync(10_000);
170+
const originalId = (manager as any).messages[0].id;
171+
172+
// Exactly one new-message emit — the original send.
173+
expect(newMessage).toHaveBeenCalledTimes(1);
174+
175+
await vi.advanceTimersByTimeAsync(10_000); // same-path retry #1
176+
177+
// Still only one new-message emit; the retry updated the original instead.
178+
expect(newMessage).toHaveBeenCalledTimes(1);
179+
// No second message row was persisted.
180+
expect((manager as any).messages).toHaveLength(1);
129181

130-
expect(bridgeCalls.filter(c => c.cmd === 'reset_path')).toHaveLength(1);
131-
// Only the original send — the resend never fires since reset_path failed.
132-
expect(bridgeCalls.filter(c => c.cmd === 'send_message')).toHaveLength(1);
182+
// The update re-points the SAME message id from CRC 111 -> 222.
183+
const call = updated.mock.calls.at(-1)![0] as any;
184+
expect(call.id).toBe(originalId);
185+
expect(call.previousAckCrc).toBe(111);
186+
expect(call.expectedAckCrc).toBe(222);
187+
expect(call.deliveryStatus).toBe('sent');
188+
189+
// The in-memory message tracks the latest attempt's CRC.
190+
expect((manager as any).messages[0].expectedAckCrc).toBe(222);
133191
});
134192

135-
it('cancels the retry if the ack for the flood resend arrives', async () => {
193+
it('settles delivered (cancels the sequence) when a later attempt is acked', async () => {
136194
const { manager, bridgeCalls } = makeManager();
137195

138196
await manager.sendMessageWithResult('hello', PUBKEY);
139-
await vi.advanceTimersByTimeAsync(10_000); // first timeout -> retry fires with ackCrc=222
140-
sendConfirmed(manager, 222);
141-
await vi.advanceTimersByTimeAsync(10_000);
197+
await vi.advanceTimersByTimeAsync(10_000); // same-path retry #1 -> CRC 222
198+
sendConfirmed(manager, 222); // ack for the retry
199+
await vi.advanceTimersByTimeAsync(30_000); // no further retries should fire
200+
201+
expect(sends(bridgeCalls)).toHaveLength(2);
202+
expect((manager as any).pendingDmRetries.size).toBe(0);
203+
});
204+
205+
it('does not resend when reset_path itself fails (flood impossible)', async () => {
206+
const { manager, bridgeCalls } = makeManager({ resetPathOk: false });
207+
const updated = vi.spyOn(dataEventEmitter, 'emitMeshCoreMessageUpdated');
142208

143-
expect(bridgeCalls.filter(c => c.cmd === 'send_message')).toHaveLength(2);
209+
await manager.sendMessageWithResult('hello', PUBKEY);
210+
await vi.advanceTimersByTimeAsync(10_000); // same-path #1
211+
await vi.advanceTimersByTimeAsync(10_000); // same-path #2
212+
await vi.advanceTimersByTimeAsync(10_000); // flood attempt -> reset_path fails
213+
214+
expect(resets(bridgeCalls)).toHaveLength(1);
215+
// 1 initial + 2 same-path only; the flood resend never fires.
216+
expect(sends(bridgeCalls)).toHaveLength(3);
217+
// The failed flood marks the message failed.
218+
expect(updated.mock.calls.some(c => (c[0] as any).deliveryStatus === 'failed')).toBe(true);
144219
});
145220

146221
it('does not schedule a retry for channel/broadcast sends', async () => {
147222
const { manager, bridgeCalls } = makeManager();
148223

149224
await manager.sendMessageWithResult('hello', undefined, 0);
150-
await vi.advanceTimersByTimeAsync(30_000);
225+
await vi.advanceTimersByTimeAsync(40_000);
226+
227+
expect(sends(bridgeCalls)).toHaveLength(1);
228+
expect(resets(bridgeCalls)).toHaveLength(0);
229+
expect((manager as any).pendingDmRetries.size).toBe(0);
230+
});
151231

152-
expect(bridgeCalls.filter(c => c.cmd === 'send_message')).toHaveLength(1);
153-
expect(bridgeCalls.some(c => c.cmd === 'reset_path')).toBe(false);
232+
it('learns/persists the new path when a flood ack arrives (contact_path_updated)', async () => {
233+
const { manager, bridgeCalls } = makeManager();
234+
// schedulePathRefresh is the existing entry point that re-reads and
235+
// persists the firmware-learned route (debounced); assert the flood-ack
236+
// push still drives it (path persistence "exactly like PathUpdated").
237+
const refresh = vi.spyOn(manager as any, 'schedulePathRefresh').mockImplementation(() => {});
238+
239+
await manager.sendMessageWithResult('hello', PUBKEY);
240+
await vi.advanceTimersByTimeAsync(10_000); // same-path #1
241+
await vi.advanceTimersByTimeAsync(10_000); // same-path #2
242+
await vi.advanceTimersByTimeAsync(10_000); // flood (reset + resend)
243+
expect(resets(bridgeCalls)).toHaveLength(1);
244+
245+
// Firmware auto-emits contact_path_updated after the flood ACK teaches the
246+
// route; the existing handler persists it onto the contact.
247+
// @ts-expect-error - exercising private handler
248+
manager.handleBridgeEvent({
249+
event_type: 'contact_path_updated',
250+
data: { public_key: PUBKEY },
251+
});
252+
253+
expect(refresh).toHaveBeenCalledWith(PUBKEY);
154254
});
155255

156-
it('clears pending retry timers on disconnect so a torn-down manager cannot retry', async () => {
256+
it('clears pending retry timers on disconnect mid-sequence so a torn-down manager cannot retry', async () => {
157257
const { manager, bridgeCalls } = makeManager();
158258
vi.spyOn(manager as any, 'stopVirtualNodeServer').mockResolvedValue(undefined);
159259

160260
await manager.sendMessageWithResult('hello', PUBKEY);
261+
await vi.advanceTimersByTimeAsync(10_000); // one same-path retry, mid-sequence
161262
expect((manager as any).pendingDmRetries.size).toBe(1);
162263

163264
await manager.disconnect();
164265
expect((manager as any).pendingDmRetries.size).toBe(0);
165266

166267
const callsBefore = bridgeCalls.length;
167-
await vi.advanceTimersByTimeAsync(30_000);
268+
await vi.advanceTimersByTimeAsync(40_000);
168269
expect(bridgeCalls.length).toBe(callsBefore);
169270
});
170271
});

0 commit comments

Comments
 (0)