Skip to content

fix(messaging): disambiguate sender-scoped packet IDs - #6624

Merged
jamesarich merged 1 commit into
meshtastic:mainfrom
simulationstation:fix/packet-sender-identity
Aug 13, 2026
Merged

fix(messaging): disambiguate sender-scoped packet IDs#6624
jamesarich merged 1 commit into
meshtastic:mainfrom
simulationstation:fix/packet-sender-identity

Conversation

@simulationstation

@simulationstation simulationstation commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • preserve packets and reactions when different senders reuse the same mesh packet ID
  • normalize PKI reaction conversation identity for persistence, parent lookup, and notifications, including compatibility for previously stored raw-channel rows
  • resolve reply and reaction parents within the current conversation, failing closed if multiple candidates remain
  • atomically claim durable Android and Desktop sends by persisted row identity instead of serializing every worker behind one process-wide mutex
  • retain an active persisted-row WorkManager request rather than replacing it, and restore only the exact claimed row on send failure
  • match outgoing delivery updates using packet ID, sender, recipient, and decoded port, refusing ambiguous updates
  • preserve orphaned-ENROUTE timeout and reconnect rearming while keying every timer and timeout update by the exact persisted row
  • surface send-persistence failures through the messaging snackbar path and keep duplicate-reaction logs free of sender or payload content

Root cause and impact

Mesh packet IDs are sender-scoped and are not suitable as globally unique database or work identifiers. Several paths previously relied on ID-only queries. In addition, the first revision compared a normalized PKI parent channel with a raw reaction channel, held a global mutex across the full radio send, replaced active persisted-row work, and left the Desktop sender on a non-atomic load/send/update sequence.

Those paths could discard a legitimate packet or reaction, hide PKI direct-message reactions, associate a reply/reaction with the wrong message, update the wrong delivery state, replace/load the wrong durable job, send the same durable Desktop row twice, or block unrelated workers behind a stalled radio call.

Behavior after this change

  • packets with the same ID from different senders are retained
  • inbound PKI reactions use the normalized DataPacket contact/channel, while legacy received PKI reactions stored on raw channel 0 remain visible
  • reply/reaction attachment fails closed when the conversation-scoped result is still ambiguous
  • delivery updates exclude inbound and different-port candidates; multiple exact outgoing matches produce no database update
  • UUID and uniquely resolved legacy Android workers transactionally claim an exact row from QUEUED to ENROUTE; only the successful claimant sends
  • repeated scheduling for the same persisted Android row uses WorkManager KEEP, preserving an already active request
  • the Desktop queue uses the same exact-row claim before sending and skips disconnected, missing, or already claimed rows
  • Android and Desktop send failure restores only that exact row and only if it remains ENROUTE, so a concurrent ACK/NAK is not overwritten
  • ACK timeouts and reconnect rearming use (myNodeNum, uuid), so rows sharing one mesh packet ID retain independent timers and only the exact still-ENROUTE row can time out
  • unrelated workers are no longer blocked by a process-wide send mutex
  • a persistence exception from the messaging send path is shown through SnackbarManager; normal input clearing remains unchanged
  • duplicate-reaction diagnostics retain packet/reply IDs and the count, but no sender identifier or emoji payload

Explicit fail-closed limitations

  • When multiple outgoing rows still match an ACK/NAK exactly, the app leaves their delivery state unchanged rather than guessing; the exact persisted send later follows the normal timeout path.
  • Reply and reaction payloads carry only reply_id, not parent sender/conversation identity. A cross-conversation reply therefore omits the quote bubble under contact-scoped lookup, and same-conversation ID ambiguity remains unattached.
  • A pre-upgrade legacy WorkManager job whose packet_id maps to zero or multiple rows fails without sending or retrying because no safe persisted row can be selected.

Regression coverage

Added coverage for PKI channel-8 persistence and notification lookup, legacy raw-channel PKI rendering, opposite-conversation/channel exclusion, sender collisions, scoped/ambiguous parents, full-packet delivery matching, real multithreaded provider overlap around Room UUID/legacy claims, unique and ambiguous legacy jobs, exact-row failure rollback, ACK-race preservation, stalled unrelated sends, and two durable rows sharing a mesh packet ID. Additional regressions prove that WorkManager keeps the first active persisted-row request, the Desktop queue has one concurrent send owner and exact failure rollback, a missing Desktop claim does not send, send-persistence exceptions reach the messaging snackbar manager, an already-claimed row is selected unambiguously for timeout ownership, colliding mesh IDs receive independent rearmed timers, and timeout mutates only the targeted persisted row.

Validation

The rebased head passed global spotlessCheck and detekt; the affected core:data, core:repository, and feature:messaging suites; exact PacketDaoTest, ReactionKeyTest, SendMessageWorkerTest, and AndroidMeshWorkerManagerTest Android-host regressions; Desktop tests and compilation; kmpSmokeCompile; and git diff --check, using at most four workers.

Coordination

Rebased as one logical commit onto current main at d8361ccd, including #6664's Compose rollback. git range-diff from the prior head is unchanged. The earlier rebase overlap in the foreground message_send analytics path still retains upstream analytics after the exact persisted row is durably enqueued. This also keeps #6630's orphaned-ENROUTE timeout/rearm behavior integrated with this PR's sender-scoped matching and atomic claim/rollback semantics. Draft PR #6598 remains complementary rather than a duplicate, but overlaps packet admission, queue lifecycle, and status persistence.

Summary by CodeRabbit

  • Bug Fixes
    • Prevented duplicate packets and reactions from being incorrectly discarded when they share an ID but come from different senders.
    • Improved reaction matching and notifications within the correct conversation, contact, and channel.
    • Improved message delivery status updates, retries, and failure recovery.
    • Prevented duplicate or ambiguous queued messages from being sent more than once.
  • Reliability
    • Improved handling of concurrent sends, acknowledgements, and stalled or failed transmissions.
    • Preserved compatibility with previously queued messages.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The change replaces generated packet IDs in queued delivery with node-scoped persisted identities. It adds atomic claim and rollback operations, contact-scoped lookups, sender-aware duplicate handling, and conversation-aware reaction matching.

Packet identity and delivery

Layer / File(s) Summary
Persisted identity and repository lifecycle
core/repository/..., core/database/..., core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt, core/data/src/commonTest/...
Repositories and DAOs use stable packet identities for persistence, status updates, claims, rollback, unique lookup, and reply resolution.
Persisted-ID queue wiring
core/repository/..., feature/messaging/..., core/service/src/androidMain/..., desktopApp/...
Message queues and worker managers pass PersistedPacketId values. Android work input includes the packet UUID and local node number.
Atomic worker delivery and status updates
core/service/..., core/testing/..., core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt, core/data/src/commonTest/...
Workers claim queued rows before sending, roll back failed sends, preserve legacy job support, and update outgoing status using the complete packet identity.

Conversation-safe packet handling

Layer / File(s) Summary
Packet and reaction matching
core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt, core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
Packet and reaction duplicates are matched by sender. Reaction metadata and notifications are resolved within the correct conversation.
Reaction entity conversation scope
core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt, core/database/src/commonTest/kotlin/org/meshtastic/core/database/entity/ReactionKeyTest.kt
Reaction conversion checks destination, contact, and channel metadata, including legacy PKI-channel cases.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: jamesarich

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Tests Prove The Path, Not The End State ⚠️ Warning Added PKI/raw reaction tests pass with old node-only filtering; the reply test inserts parent B first, so old LIMIT 1 selects it; the duplicate-reaction test stubs only the new plural lookup, so ol... Stub the legacy reaction lookup with an existing row, reverse parent insertion order or assert exclusion of the other parent, and add opposite-channel/cross-conversation assertions to PKI tests.
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Sibling Call Sites And Presence Semantics ✅ Passed The HEAD^..HEAD diff changes no NodeItem or measurement fields, adds no RSSI/temperature/current/voltage defaults, and nullable Reaction.snr/rssi predate this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: disambiguating packet IDs by sender across messaging and persistence flows.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added bugfix PR tag desktop Desktop target labels Aug 11, 2026
@simulationstation simulationstation changed the title fix(messaging): scope packet identity by sender and row fix(messaging): disambiguate sender-scoped packet IDs Aug 11, 2026
@simulationstation
simulationstation marked this pull request as ready for review August 11, 2026 22:28

@jamesarich jamesarich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core insight here is right — mesh packet IDs are not globally unique and treating them as such is a real defect class — and the WorkManager re-keying onto (myNodeNum, uuid) row identity is a solid improvement. I also confirmed this does not reintroduce the LazyColumn duplicate-key crash class: MessageListPaged keys on uuid (the Room rowid), reaction lists key on (user.id, emoji), and the packet_id index is already non-unique. Two things to fix before this can merge.

Blocking: the new belongsTo() filter hides inbound reactions on PKI direct messages

Packet.belongsTo() (core/database/.../entity/Packet.kt:92) gates on ContactKey(packet.contact_key).channel != channel, but those two values are derived through different paths:

  • Packet.contact_key is built from DataPacket.channel, which MeshDataMapper.kt:44 normalizes to NodeAddress.PKC_CHANNEL_INDEX (8) whenever pki_encrypted == true.
  • ReactionEntity.channel for an inbound reaction is the raw MeshPacket.channel (MeshDataHandlerImpl.kt:604), which is 0 on a PKC DM — pki_encrypted is a separate flag, not the channel index.

So on any PKC direct message — the default for DMs on current firmware — the parent row has contact_key = "8!…" while the reaction row has channel = 0, belongsTo returns false, and the reaction is filtered out of toMessage().emojis. Because this filters at read time it applies to rows already in the database, so existing DM reactions disappear on upgrade rather than just new ones failing to land.

The asymmetry is visible in the code: locally-sent reactions store channel = ContactKey(contactKey).channel (= 8) at MessagingControllerImpl.kt:79, so your own reactions survive the filter and your peer's do not. The same root cause hits MeshDataHandlerImpl.kt:585 (val contactKey = "${packet.channel}$contactId"), so the parent lookup at :625 also misses and reaction notifications stop for DMs.

Suggested fix: derive the channel from dataMapper.toDataPacket(packet) in both places rather than re-deriving from the raw proto, so the PKC normalization is applied exactly once and consistently. Worth adding ReactionKeyTest cases at the PKC index — every current case uses a channel-0 contact key ("0^all", "0!aaaa0001"), which is why this passed.

Please replace the process-wide send mutex

SendMessageWorker.kt:96 adds a companion sendMutex that is acquired at :55 and held across radioController.sendMessage(packetData) at :67. Holding a mutex across a radio API call is a hazard this repo has been bitten by before (the firmware SWR wedge): if the send stalls waiting on queue status, every other SendMessageWorker blocks behind it until WorkManager's 10-minute window kills them. It's also asymmetric — DesktopMessageQueue has no equivalent serialization. Since the lock exists only to cover the legacy/UUID coexistence window, a compare-and-set of QUEUED → ENROUTE before the send (instead of after, at :68) gives the same no-double-send guarantee without a global lock.

Non-blocking, but please sign off explicitly

Several fail-closed choices have user-visible consequences worth stating in the description: an ambiguous handleAckNak (MeshDataHandlerImpl.kt:411) drops the ACK, leaving the message ENROUTE forever; reply parents scoped to contact_key means a reply quoting a message from another conversation loses its quote bubble; and a legacy WorkManager job with an ambiguous packet ID returns Result.failure(), so that queued message never sends and never retries.

CI

Your two red checks are not your faultbuild-flatpak fails on a Gradle distribution checksum mismatch because main still pins 9.7.0 in scripts/verify-flatpak/desktop-offline.yaml (the #6611 rollback updated the wrapper but not the manifest). Your PR only trips it because it touches desktopApp/, which gates that job. #6625 fixes exactly those two lines and is in the merge queue now — rebase once it lands and both should clear.

@simulationstation
simulationstation force-pushed the fix/packet-sender-identity branch from 137f80a to e9b1306 Compare August 12, 2026 01:20

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt (1)

44-65: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Atomically claim the row before sending.

Two enqueue calls can both read the same QUEUED row before either call updates its status. Both calls then send the packet. Use claimQueuedPacket before sendMessage. Roll back only when sendMessage throws.

Proposed fix
-            val packetData =
-                packetRepository.getPacketByPersistedId(persistedId)
-                    ?: return@launch // Packet no longer exists in DB? Do not retry.
+            val claimed = packetRepository.claimQueuedPacket(persistedId) ?: return@launch
+            if (claimed.packet.status != MessageStatus.QUEUED) return@launch
 
             try {
-                radioController.sendMessage(packetData)
-                packetRepository.updateMessageStatus(persistedId, MessageStatus.ENROUTE)
+                radioController.sendMessage(claimed.packet)
             } catch (`@Suppress`("TooGenericExceptionCaught") e: Exception) {
-                Logger.w(e) { "Failed to send packet ${packetData.id}, re-queuing" }
-                packetRepository.updateMessageStatus(persistedId, MessageStatus.QUEUED)
+                Logger.w(e) { "Failed to send packet ${claimed.packet.id}, re-queuing" }
+                packetRepository.rollbackEnroutePacket(claimed.id)
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt`
around lines 44 - 65, Update enqueue around claimQueuedPacket to atomically
claim the queued packet before calling radioController.sendMessage, and return
without sending when the claim fails because another enqueue already claimed it.
Keep the existing success status update after sending, and update the status
back to QUEUED only in the sendMessage exception path; do not roll back for
connection checks, missing packets, or failed claims.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt`:
- Around line 613-616: Update the duplicate-reaction log in MeshDataHandlerImpl
to remove the fromId sender identifier and emoji payload from the message.
Retain only non-sensitive context such as packetId, replyId, and the existing
reaction count.

In
`@core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt`:
- Around line 123-125: Update the sendMessage flow to provide its errorEvents to
safeLaunch so the IllegalStateException from
PacketRepositoryImpl.insertRoomPacket is surfaced through the send-failure
snackbar. Preserve the existing input-clearing behavior and use the established
error event for failed sends.

In
`@core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonPacketRepositoryTest.kt`:
- Around line 171-201: Make this test exercise a real concurrent race by
providing the repository with a multi-threaded IO dispatcher instead of the
single-threaded UnconfinedTestDispatcher, then invoke claimQueuedPacket and
claimQueuedPacketByPacketIdIfUnique through that racingRepository. Preserve the
existing assertions proving one caller performs the claim and the other observes
it, and ensure the test fails if the production claim operation is no longer
atomic.

In
`@core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidMeshWorkerManager.kt`:
- Around line 41-46: In AndroidMeshWorkerManager’s enqueueUniqueWork call,
change the policy for the persisted send-message work name to
ExistingWorkPolicy.KEEP. Preserve the existing unique-name construction and
workRequest so repeated scheduling retains the active worker instead of
canceling and replacing it.

---

Outside diff comments:
In
`@desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt`:
- Around line 44-65: Update enqueue around claimQueuedPacket to atomically claim
the queued packet before calling radioController.sendMessage, and return without
sending when the claim fails because another enqueue already claimed it. Keep
the existing success status update after sending, and update the status back to
QUEUED only in the sendMessage exception path; do not roll back for connection
checks, missing packets, or failed claims.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fe903cf2-0151-409a-a971-2748fa9dc0f2

📥 Commits

Reviewing files that changed from the base of the PR and between 6c06601 and e9b1306.

📒 Files selected for processing (22)
  • core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
  • core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
  • core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
  • core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
  • core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
  • core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt
  • core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonPacketRepositoryTest.kt
  • core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
  • core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
  • core/database/src/commonTest/kotlin/org/meshtastic/core/database/entity/ReactionKeyTest.kt
  • core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshWorkerManager.kt
  • core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MessageQueue.kt
  • core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
  • core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt
  • core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCaseTest.kt
  • core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.kt
  • core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidMeshWorkerManager.kt
  • core/service/src/androidMain/kotlin/org/meshtastic/core/service/worker/SendMessageWorker.kt
  • core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
  • desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt
  • desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt
  • feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/worker/WorkManagerMessageQueue.kt

@simulationstation

Copy link
Copy Markdown
Contributor Author

Thanks — addressed both blockers in e9b1306b3. PKI reactions now use normalized DataPacket identity for storage, parent lookup, and notifications, with compatibility for existing raw-channel PKI rows. The global worker mutex is replaced by transactional UUID/legacy QUEUED to ENROUTE claims; failure rolls back only the exact still-ENROUTE row, preserving ACK/NAK races. Added PKI render/notification, concurrent one-owner claim, rollback/ACK-race, and stalled-unrelated-worker regressions. The PR description now explicitly documents the three fail-closed limitations you requested.

@simulationstation
simulationstation force-pushed the fix/packet-sender-identity branch from e9b1306 to e45e191 Compare August 12, 2026 01:55
@simulationstation

Copy link
Copy Markdown
Contributor Author

The CodeRabbit follow-ups are addressed in e45e1918ba: privacy-safe logging, UI snackbar feedback for failed sends, a genuinely concurrent Room claim regression, WorkManager KEEP with the real test scheduler, and Desktop atomic claim/exact rollback parity. All four inline threads were answered and resolved; affected data/database/messaging/desktop/service suites plus Spotless and Detekt pass.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

@simulationstation I will perform a complete review of pull request #6624, including the follow-up changes in e45e1918ba.

⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 19 minutes.

@simulationstation
simulationstation force-pushed the fix/packet-sender-identity branch from e45e191 to c38f250 Compare August 12, 2026 03:43
@simulationstation

Copy link
Copy Markdown
Contributor Author

Rebased the branch as one logical commit (c38f250f2) onto current main at bfb66eb98. This base includes #6625, so the stale Flatpak checksum failures from the previous head should be superseded by the new run.

I also re-audited the remaining CodeRabbit test-strength suggestions against the current tests. The requested end states are already pinned directly: the duplicate-reaction regression stubs the legacy singular lookup with an existing row and still requires insertion for the other sender; the reply regression inserts the wrong-conversation parent first and requires the correct parent in both flow and DAO lookup; and the PKI regressions require exclusion from both the opposite conversation and opposite channel while preserving legacy raw-channel rows. I did not add redundant assertions.

Current-head validation passed the affected data/database/repository/service/messaging/Desktop test suites, spotlessApply, spotlessCheck, detekt, kmpSmokeCompile, and git diff --check (Gradle capped at four workers).

@jamesarich, when convenient, would you please take another look? All requested code, limitation-documentation, test, and rebase items are now complete.

@simulationstation
simulationstation force-pushed the fix/packet-sender-identity branch from c38f250 to cab3ae9 Compare August 12, 2026 04:41
@simulationstation

Copy link
Copy Markdown
Contributor Author

Rebased again as one logical commit (cab3ae9cf) onto current main at 8ea6e43f8 and integrated merged #6630.

The combined implementation preserves sender/contact-scoped matching and atomic exact-row claim/rollback while carrying #6630's orphaned-ENROUTE recovery forward. ACK timeout ownership, reconnect rearming, and conditional timeout updates now use (myNodeNum, uuid) rather than the globally ambiguous mesh packet ID. Added deterministic regressions for independent timers on colliding mesh IDs, exact-row timeout, and recovery of the sole already-claimed row identity.

Validation is green: affected database/data/repository/service/messaging/Desktop suites, current-head spotlessCheck, detekt, kmpSmokeCompile, and git diff --check, with Gradle capped at four workers. There was no new unresolved review feedback before this push.

@jamesarich, the current head is ready for re-review when convenient.

@ygeshors

Copy link
Copy Markdown

I hit what looks like the same class of bug, on a different internal queue,
which might be useful context here since the trigger conditions are nearly
identical to the original report:

Setup: Heltec V3 (ESP32-S3), firmware 2.8.0, EU_868/LongFast, Wi-Fi OFF
(BLE-only to phone), MQTT enabled with proxy_to_client_enabled=true
relaying a busy public channel (mqtt.meshtastic.org).

Symptom: messages sent from the phone get no delivery confirmation —
the ROUTING_APP ACK/NAK for the phone's own message never arrives back,
indefinitely. Disabling the phone-side MQTT relay only (no device config
change, no reboot) reliably avoids it; the same send over Wi-Fi instead of
BLE always resolves correctly.

From source review (firmware @ HEAD 2.8.0-dev): MeshService::sendToPhone()
(src/mesh/MeshService.cpp:465-511) is the single BLE delivery path for
received mesh packets, MQTT-downlink-reinjected packets
(MQTT::onClientProxyReceive -> onReceiveProto ->
router->enqueueReceivedMessage(), src/mqtt/MQTT.cpp:189-197), and the
ROUTING_APP ACK/NAK for the phone's own outgoing message — all competing
for the same toPhoneQueue (MAX_RX_TOPHONE, 32 slots on ESP32-S3 without
PSRAM). When full, non-text-message portnums (including ROUTING_APP) are
dropped silently (MeshService.cpp:489-501), with no Routing_Error NAK
generated to compensate.

Same root mechanism as this issue (small fixed-size internal queue +
unthrottled MQTT downlink volume from a busy public channel), just a
different queue (toPhoneQueue vs fromRadioQueue). Happy to open a
separate issue with full repro steps if that's preferred over piling onto
this one — let me know.

simulationstation commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — this is useful context, and it appears distinct from #6624. This PR fixes Android-side sender-scoped persistence and durable-send ownership; it cannot prevent a firmware toPhoneQueue drop.

The current firmware paths support the proposed mechanism: proxied MQTT downlinks are reinjected through the receive pipeline, and MeshService::sendToPhone() drops a non-text packet when toPhoneQueue is full. The field attribution still needs the queue-full event captured during a repro.

Your existing #6601 is the right Android-facing symptom tracker. A focused meshtastic/firmware issue would be useful for the suspected firmware loss path; please link #6601 and related firmware issue meshtastic/firmware#9674, include the exact firmware SHA/hardware memory configuration, and capture serial logs around the send—especially any ToPhone queue full, drop packet line. I would keep that firmware fix separate from this Android PR.

Update: the deterministic source-level regression and proposed priority invariant are now tracked in meshtastic/firmware#11439.

Copy link
Copy Markdown
Contributor Author

Rebased the branch as one logical commit (d94a2b60f) onto current main at 1b89b6392.

The only overlap was the foreground message_send analytics path: the merged code now enqueues the exact persisted row identity and then records the upstream analytics action. Current upstream full-timestamp state and #6624's send-failure snackbar behavior are both retained.

Validation is green: global Spotless/Detekt; affected data, repository, and messaging suites; exact database and Android worker regressions; Desktop tests/compile; KMP smoke compile; and git diff --check, using at most four workers.

@jamesarich, the current head is ready for re-review when convenient.

@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
2892 1 2891 0
View the top 1 failed test(s) by shortest run time
org.meshtastic.core.ui.viewmodel.ConnectionsViewModelTest::connected older known node exposes Android firmware update notice()[jvm]
Stack Traces | 0.107s run time
org.opentest4j.AssertionFailedError: expected: <1> but was: <0>
	at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1210)
	at kotlin.test.junit5.JUnit5Asserter.assertEquals(JUnitSupport.kt:32)
	at kotlin.test.AssertionsKt__AssertionsKt.assertEquals(Assertions.kt:63)
	at kotlin.test.AssertionsKt.assertEquals(Unknown Source)
	at kotlin.test.AssertionsKt__AssertionsKt.assertEquals$default(Assertions.kt:62)
	at kotlin.test.AssertionsKt.assertEquals$default(Unknown Source)
	at org.meshtastic.core.ui.viewmodel.ConnectionsViewModelTest$connected older known node exposes Android firmware update notice$1.invokeSuspend(ConnectionsViewModelTest.kt:201)
	at org.meshtastic.core.ui.viewmodel.ConnectionsViewModelTest$connected older known node exposes Android firmware update notice$1.invoke(ConnectionsViewModelTest.kt)
	at org.meshtastic.core.ui.viewmodel.ConnectionsViewModelTest$connected older known node exposes Android firmware update notice$1.invoke(ConnectionsViewModelTest.kt)
	at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$2$1$1.invokeSuspend(TestBuilders.kt:317)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
	at kotlinx.coroutines.test.TestDispatcher.processEvent$kotlinx_coroutines_test(TestDispatcher.kt:24)
	at kotlinx.coroutines.test.TestCoroutineScheduler.tryRunNextTaskUnless$kotlinx_coroutines_test(TestCoroutineScheduler.kt:98)
	at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$2$1$workRunner$1.invokeSuspend(TestBuilders.kt:326)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
	at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:256)
	at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:54)
	at kotlinx.coroutines.BuildersKt__BuildersKt.runBlockingImpl(Builders.kt:30)
	at kotlinx.coroutines.BuildersKt.runBlockingImpl(Unknown Source)
	at kotlinx.coroutines.BuildersKt__Builders_concurrentKt.runBlockingK(Builders.concurrent.kt:172)
	at kotlinx.coroutines.BuildersKt.runBlockingK(Unknown Source)
	at kotlinx.coroutines.BuildersKt__Builders_concurrentKt.runBlockingK$default(Builders.concurrent.kt:157)
	at kotlinx.coroutines.BuildersKt.runBlockingK$default(Unknown Source)
	at kotlinx.coroutines.test.TestBuildersJvmKt.createTestResult(TestBuildersJvm.kt:10)
	at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:309)
	at kotlinx.coroutines.test.TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:1)
	at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:167)
	at kotlinx.coroutines.test.TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:1)
	at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt.runTest-8Mi8wO0$default(TestBuilders.kt:159)
	at kotlinx.coroutines.test.TestBuildersKt.runTest-8Mi8wO0$default(TestBuilders.kt:1)
	at org.meshtastic.core.ui.viewmodel.ConnectionsViewModelTest.connected older known node exposes Android firmware update notice(ConnectionsViewModelTest.kt:179)

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@simulationstation
simulationstation force-pushed the fix/packet-sender-identity branch from d94a2b6 to acae64f Compare August 13, 2026 03:25

Copy link
Copy Markdown
Contributor Author

Rebased the existing single logical commit to acae64fb7 on current main at d8361ccd, which includes #6664's Compose Multiplatform rollback. git range-diff is unchanged, so this is a base refresh only and supersedes the prior unrelated Compose/Flatpak failures.

Current-base validation is green with at most four workers: global Spotless and Detekt; core:data and core:repository; exact Room packet-DAO and reaction regressions; exact Android send-worker regressions; feature:messaging; Desktop tests; and KMP smoke compilation. All review threads remain resolved.

@jamesarich, the current head is ready for re-review when convenient.

@jamesarich jamesarich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed against both blocking items. Both are resolved, and the reaction fix is better than what I proposed.

1. PKI reaction filter — fixed at the write path, plus a legacy read shim

I asked you to derive the channel through toDataPacket so the PKC normalization applies exactly once. That's what landed: contactKey = "${dataPacket.channel}$contactId" and the duplicate "${originalPacket.channel}$targetId" derivation are both gone, replaced by a single DataPacket.contactKey(myNodeNum) helper, and channel = packet.channel no longer feeds ReactionEntity. MeshDataHandlerTest now asserts NodeAddress.PKC_CHANNEL_INDEX is what actually gets persisted for an inbound PKI reaction, with the notification lookup resolving "8!remote" — so new rows agree with their parent's contact_key.

The part I hadn't asked for is the one that matters most: isLegacyInboundPkiChannel in belongsTo() keeps rows already in the database visible. My concern was that read-time filtering would make existing DM reactions vanish on upgrade, and fixing only the write path wouldn't have addressed that. The three ReactionKeyTest cases pin the shape exactly — normalized inbound PKI reaction attaches to its direct-message parent, pre-fix raw-channel inbound PKI reaction remains visible, and wrong non-PKI reaction channel remains excluded proving the shim didn't over-broaden into a general channel-mismatch bypass. Since the write path is fixed, the "legacy" name is accurate rather than aspirational.

2. Send mutex — replaced with an atomic row claim

The process-wide sendMutex is gone. claimQueuedPacket / claimQueuedPacketByPacketIdIfUnique do the QUEUED → ENROUTE transition in the DAO and return the pre-claim status, so the caller that observes QUEUED owns the send and a later caller observes ENROUTE and declines — the compare-and-set-before-send I suggested, but atomic in SQL rather than advisory in process memory, which is strictly better and works across the Desktop queue too.

a stalled radio send does not block an unrelated claimed row is the direct regression test for the wedge I was worried about, and queued row can be claimed for send only once, concurrent enqueue sends only the caller that owns the queued claim, send failure rolls back only the exact claimed row, and missing claim neither sends nor rolls back cover the rest of the state space.

3. Fail-closed sign-off

The "Explicit fail-closed limitations" section covers what I asked for, including the cross-conversation reply losing its quote bubble.

Before merge

Base is d8361ccd1; main is now b4bedd92f (#6670, #6640, #6618, #6663). GitHub reports no textual conflict, and the merge queue rebases and runs the merge_group gate on the merged result, so this is fine to enqueue — I just don't want the green tick on the current base read as verification of the merged state.

LGTM. Nicely done on both.

@jamesarich
jamesarich added this pull request to the merge queue Aug 13, 2026
Merged via the queue into meshtastic:main with commit 2f38736 Aug 13, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix PR tag desktop Desktop target

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants