fix(meshcore): auto-retry DM via same-path + flood cadence on ack timeout#3980
Conversation
DMs sent on a stale cached path went out once and were never retried, so they silently never arrived (issue #3977). This automates exactly what the existing "Reset Path" button already does by hand: if no SendConfirmed ack arrives within the firmware's own estTimeout (+20% margin), reset the contact's cached path and resend once via flood. The retry's own send isn't itself tracked, so a second miss is left to the frontend's existing per-message ack timer, same as any other unacked send today. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y9S9qVAj7jW912hpYscXJW
|
Claude finished @Yeraze's task —— View job PR Review: fix(meshcore): auto-retry DM via flood on ack timeoutTasks:
Overall AssessmentThis is a well-scoped, thoughtfully designed "Phase 1" fix. The implementation is clean, the failure modes are handled correctly, and the commentary/documentation is exemplary. The deliberate choice not to track the retry's own ack is reasonable and clearly explained. A few items worth considering: Potential Bugs / Issues1.
// current
this.pendingDmRetries.set(ackCrc, { toPublicKey, text, timer });
// if an entry already exists under ackCrc, the old timer is orphanedA minimal guard: private scheduleDmAckTimeout(...): void {
const existing = this.pendingDmRetries.get(ackCrc);
if (existing) clearTimeout(existing.timer); // don't orphan the old timer
const timer = setTimeout(...);
this.pendingDmRetries.set(ackCrc, { toPublicKey, text, timer });
}2.
await this.runSerialized(async () => {
const reset = await this.resetContactPath(pending.toPublicKey);
if (!reset) return;
await this.performScopedSend(pending.text, pending.toPublicKey, undefined, undefined, true);
});(This also eliminates the nested 3. Missing The const reset = await this.resetContactPath(pending.toPublicKey);
if (!reset || !this.connected) return;Code Quality & Best Practices4. The 5. The 20% margin ( Consider hoisting it to a named constant alongside the other private static readonly DM_ACK_TIMEOUT_MARGIN = 1.2;This makes it tunable from one place and documents the intent better than an inline literal. 6. Test CoverageThe test suite is solid:
One gap: no test for the PerformanceNo concerns. The SecurityNo concerns. The retry resends the original Minor NitThe test file comment block at lines 1–11 is unusually verbose for a test file header, but it's in keeping with the codebase's documentation style and accurately describes the behavior, so this is just a style observation. Summary: The implementation is correct and production-ready for a Phase 1. Items 1 (timer orphan on CRC collision) and 2 (race between |
- scheduleDmAckTimeout: clear an existing timer before overwriting its map entry, so a rare ackCrc collision between two in-flight DMs can't orphan the first timer. - handleDmAckTimeout: run resetContactPath + the flood resend inside runSerialized so they can't interleave with a concurrent user-initiated send's scope-assert->send pair; re-check `connected` after the reset-path await returns. - Hoist the 20% ack-timeout margin to a named DM_ACK_TIMEOUT_MARGIN constant. - Add a test for the reset_path-failure path (no resend attempted). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y9S9qVAj7jW912hpYscXJW
|
Thanks for the review — addressed all three bug findings plus the readability/test-coverage suggestions in 5c8be4d:
Full manager suite (248 tests) + typecheck + lint all clean. Still a draft — not merging. -- Authored by Roger 🤓 Generated by Claude Code |
|
Claude finished @Yeraze's task —— View job PR Review: fix(meshcore): auto-retry DM via flood on ack timeoutTasks:
Overall AssessmentThe previous review findings have all been addressed cleanly in Bug Fixes VerifiedAll three bugs from the previous review are correctly resolved: 1. ackCrc collision (timer orphan) — 2. 3. Missing Code QualityImplementation is clean and well-documented.
One minor observation: the Test CoverageTest suite is complete. The new
The Performance & SecurityNo concerns. The SummaryThe implementation is correct, all previous findings addressed, test coverage is complete, and the code is well-documented. Ready to merge when the author is satisfied with hardware validation. |
…#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
|
Claude finished @Yeraze's task —— View job PR Review: fix(meshcore): auto-retry DM via flood on ack timeoutTasks:
Overall AssessmentThis is the third review pass against this PR. The two prior rounds addressed ackCrc collision, serialization ordering, connected-guard placement, the opaque margin constant, and the missing What's New vs the Previous RoundThe first review covered a one-shot "flood on timeout" approach. The code has since been upgraded to match MeshCore FAQ §5.3 exactly:
This is substantially more code than was originally described in the PR summary. The test file header and test names accurately reflect the new behavior, but the PR description itself still describes only the Phase 1 (one-shot flood) design. That's a documentation mismatch to be aware of when reading the PR body — the actual implementation is the correct fuller behavior, but a reviewer reading only the PR description would think less is here. Bugs / Issues1.
const result = await this.performScopedSend(..., true);
if (!result.ok || result.expectedAckCrc == null || result.estTimeout == null) {
this.failDmDelivery(pending.messageId, ackCrc); // ← uses outer ackCrc, not result.expectedAckCrc
return;
}When // if a new CRC was assigned, cancel its timer too before failing
this.failDmDelivery(pending.messageId, result.expectedAckCrc ?? ackCrc);2. Disconnect guard path in If the same message's retry sequence hits disconnect on two separate timer callbacks (e.g., a disconnect races with an already-queued No action needed — the duplicate is harmless. Code Quality3.
4.
5. Cancels the previous attempt's fail timer → patches the existing message in place → arms a new fail timer for the new CRC. The logic correctly skips re-arming on terminal statuses. This is well-implemented. 6. The new emitter method correctly injects Test CoverageThe test suite is comprehensive for the new full-cadence behavior:
One small gap: the test at line 140 ( PerformanceNo concerns. The Map is O(1), retry budget is finite and small (at most 3 extra sends per DM), and the flood path runs inside the existing serialized queue so it can't pile up. SecurityNo concerns. PR Description vs Implementation GapThe PR summary still describes the old Phase 1 ("reset path and resend once via flood"). The implementation is now the full official cadence (2 same-path + 1 flood). Consider updating the PR body to match — reviewers and future git-blame readers will thank you. Summary: The implementation is correct and complete for the full MeshCore retry cadence. Item 1 (edge-case |
|
Thanks for the third pass. I traced through finding #1 carefully before deciding whether to change it, and I don't think it's actually a bug — writing up the reasoning so it's on record rather than silently skipping it:
So at line ~2872, when Not applying this one — happy to be shown a concrete repro where it actually leaks a timer, but I couldn't construct one. Finding #2 needs no action per your own note. Everything else from all three rounds is applied and tested (251 passing). Updated the PR description to reflect the fuller same-path+flood cadence now implemented, rather than the original one-shot-flood Phase 1. -- Authored by Roger 🤓 Generated by Claude Code |
…-heard miss (#3979) (#3988) Part 2 of #3979 (builds on merged Part 1, PR #3987, which made the "heard repeaters" echo-attribution trustworthy). A MeshCore channel/broadcast send is an unacked, fire-and-forget flood, so there is no firmware ACK to confirm delivery. When the Part 1 echo signal reports that ZERO repeaters re-flooded our packet within 30s, the message likely reached no one. This adds an opt-in, one-shot resend for that case. - New global setting `meshcoreChannelRetryEnabled` (default OFF), wired through VALID_SETTINGS_KEYS, SettingsContext, and a new "MeshCore Messaging" section in global Settings (SettingsTab). - Applies to AUTOMATED channel senders only (Automation Engine action.sendMessage, Auto-Acknowledge, auto-responder, auto-announce, timer triggers), threaded via a new `autoRetryOnMiss` opt-in flag on sendMessage/sendMessageWithResult/performScopedSend. User-initiated route sends never opt in, so they never retry. - New channel-retry state machine (`pendingChannelRetries`, `maybeArmChannelRetry`, `handleChannelRetryTimeout`) parallel to and non-colliding with the DM ack-retry (#3977/#3980): the DM path keys on the firmware ACK CRC (toPublicKey set); the channel path keys on the echo-heard signal (channelIdx set) — mutually exclusive branches of performScopedSend. - One-shot: the resend goes out with isAutoRetry=true (no new message row, no `message`/bus emit — so it cannot spawn a fresh automation) and autoRetryOnMiss=false (re-registered for echo correlation but never re-armed). - Pending channel retries cleared in the disconnect() bulk cleanup. - Tests: src/server/meshcoreManager.channelRetry.test.ts covers zero-heard resend, heard→no-resend, no-bus-reentry, one-shot, user-send never arms, setting-off never arms, disconnect clears, and DM/channel non-collision. - Docs: docs/features/meshcore.md (new section) + automation-engine.md note. Closes #3979 Claude-Session: https://claude.ai/code/session_015AFBA76hsjqhsXe1BdnYub Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
DM_SAME_PATH_RETRIES(2) times; once those are exhausted, reset the cached path (same effect as the existing manual "Reset Path" button) and resend via flood up toDM_FLOOD_RETRIES(1) time; if still unacked, mark the message failed. That's 2 same-path + 1 flood = 3 retries, 4 transmissions total including the initial send.messageevent. Instead, each retry re-points the original message's trackedexpectedAckCrc/estTimeoutvia a newmeshcore:message:updatedevent, so the UI shows one message bubble that ends indelivered(any ACK) orfailed(all retries exhausted) — never a duplicated/contradictory pair of bubbles.disconnect(), alongside the existingpendingCommandscleanup.reset_pathrunning outside the serialized send queue, a redundant-but-cheapconnectedre-check, and test coverage for thereset_pathfailure path) were fixed and verified. See PR comments for details, including a documented analysis of one review suggestion that turned out to be a false positive on closer trace-through of the CRC hand-off between attempts.Changes
src/server/meshcoreManager.ts:pendingDmRetriesmap keyed by the firmware'sexpectedAckCrc, carrying remaining same-path/flood retry budget per in-flight DM.performScopedSendarms the initial retry timer for DM sends (not channel sends, not retry resends themselves — gated byisAutoRetry).send_confirmedbridge-event handler cancels the timer when the ack matches.scheduleDmAckTimeout/handleDmAckTimeoutdrive the same-path → flood → fail cadence;updateDmAttempt/failDmDeliveryre-point or terminate the tracked message viaemitMeshCoreMessageUpdated.disconnect()clears any pending retry timers.src/server/services/dataEventEmitter.ts: newemitMeshCoreMessageUpdatedevent.src/components/MeshCore/hooks/useMeshCore.ts:onMessageUpdatedhandler keeps a single message bubble in sync across retries, re-arming its own client-side fail-safe timer against each new CRC.src/server/meshcoreManager.dmRetry.test.ts: covers ack-before-timeout, same-path retries, flood fallback after same-path exhaustion, terminal failure after all retries, single-bubble behavior (no duplicate emits),reset_pathfailure, channel-send exclusion, and timer cleanup on disconnect.Test plan
npx vitest run src/server/meshcoreManager— 251 tests passing (existing + new).npx tsc --noEmit— clean.npx eslint src/server/meshcoreManager.ts— no new errors (pre-existing repo-wide findings confirmed present onmainindependent of this change).npx vitest run— only pre-existing failures unrelated to MeshCore (missingprotobufs/meshtastic/*.protoasset in this sandbox, confirmed present onmainbefore this branch).🤖 Generated with Claude Code
https://claude.ai/code/session_01Y9S9qVAj7jW912hpYscXJW