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 */
1214import { describe , it , expect , beforeEach , afterEach , vi } from 'vitest' ;
1315import { MeshCoreManager , MeshCoreDeviceType } from './meshcoreManager.js' ;
1416import databaseService from '../services/database.js' ;
17+ import { dataEventEmitter } from './services/dataEventEmitter.js' ;
1518
1619interface 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
7180const 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
7384describe ( '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