Skip to content

Claim sent messages per-row and log thread validation failures#102

Open
pmanot wants to merge 1 commit into
mainfrom
purav/fix-sent-thread-validation
Open

Claim sent messages per-row and log thread validation failures#102
pmanot wants to merge 1 commit into
mainfrom
purav/fix-sent-thread-validation

Conversation

@pmanot

@pmanot pmanot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

When Messages.app delivers a send under a different chat GUID than the one targeted (service or handle-formatting flip for the same contact), waitForMessageSend rejected the whole batch and resolved the send as a bare boolean(true). The client then had no message to reconcile its optimistic echo against, leaving it stuck on "Still sending…" indefinitely (DESK-28166).

  • Validate each sent row individually instead of allSatisfy over the batch, so one foreign or unjoined row doesn't discard the rest
  • Accept same-recipient addresses (email casing, phone with/without country prefix) before falling back to the contacts-database comparison, which returns false when the sent handle isn't on the contact card
  • Log hashed target/sent thread GUIDs and report to Sentry when validation still fails, so future rageshakes show which mismatch flavor occurred

partial fix for DESK-28166

When Messages.app delivers a send under a different chat GUID than the
one targeted (service or handle-formatting flip for the same contact),
waitForMessageSend rejected the whole batch and resolved the send as a
bare boolean(true). The client then had no message to reconcile its
optimistic echo against, leaving it stuck on "Still sending…"
indefinitely (DESK-28166).

- Validate each sent row individually instead of allSatisfy over the
  batch, so one foreign or unjoined row doesn't discard the rest
- Accept same-recipient addresses (email casing, phone with/without
  country prefix) before falling back to the contacts-database
  comparison, which returns false when the sent handle isn't on the
  contact card
- Log hashed target/sent thread GUIDs and report to Sentry when
  validation still fails, so future rageshakes show which mismatch
  flavor occurred

Fixes DESK-28166
@indent-zero

indent-zero Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor
PR Summary

Fixes DESK-28166 where iMessage sends that landed under a different chat GUID (service or handle-formatting flip for the same recipient) invalidated the whole batch and returned .boolean(true), leaving the client's optimistic echo stuck on "Still sending…". The fix validates each sent row individually and adds a same-recipient heuristic so a foreign or unjoined row no longer discards the rest, while emitting hashed diagnostic logs plus a Sentry report so future rageshakes surface which mismatch flavor occurred.

  • waitForMessageSend now delegates to a new nonisolated static PlatformAPI.validSentMessageIDs(...) that filters per row instead of allSatisfy-ing over the batch; ordering and the sentMessageIDs/sentThreadIDs 1:1 pairing are preserved via zip(...).filter{…}.map(\.0).
  • New addressesReferToSameRecipient helper in Utilities/ThreadID.swift accepts email case variants and phone numbers with/without country prefix (case-insensitive equality → digits-only suffix match with a ≥7-digit floor to avoid shortcode collisions), running before the contacts-DB isSameContact fallback which false-negatives when the sent handle isn't on the contact card.
  • On partial mismatch, logs hashed target/sent thread GUIDs via Hasher.thread.tokenizeRemembering and calls reportErrorMessage with a N/M summary; the all-foreign path still returns .boolean(true) (unchanged), and sentMessages/validateLinkedMessageIDs now operate on the filtered set.
  • Adds SentThreadValidationTests.swift covering exact-match, service/formatting flips, contacts fallback, per-row foreign drop, unjoined-row drop, all-foreign empty result, and rejection cases for the same-recipient helper (shortcodes, differing emails, nils).

Issues

2 potential issues found:

  • addressesReferToSameRecipient strips non-digits, so a group thread's chat<digits> address can alias a 1-1 recipient phone number whose digits are a suffix of the chat GUID; trigger is any cross-type send where the earlier sentThreadID == targetThreadID short-circuit doesn't fire (differing iMessage;+;chat… vs iMessage;-;+1…). Very unlikely with real (long/random) chat IDs, but the helper isn't gated by thread type. → Autofix
  • Any validSentMessageIDs.count < sentMessageIDs.count now Sentry-reports via reportErrorMessage, including the "concurrent send from another Apple device" case the fix explicitly handles gracefully — expect steady Sentry noise for users with multiple Apple devices sending in the same window. Intentional per the commit message ("show which mismatch flavor occurred"), but worth confirming the volume is acceptable or downgrading benign cases to log-only. → Autofix

CI Checks

Waiting for CI checks...


⚡ Autofix All Issues

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Improved message send validation so valid messages are no longer blocked by unrelated invalid entries in the same batch.
    • Better handles recipient matching across different phone-number formats and case-insensitive email addresses.
    • Adds clearer error reporting when some messages still fail validation, while allowing the valid ones to continue.

Walkthrough

Changes sent-message/thread validation in waitForMessageSend from an all-or-nothing check to per-message filtering via a new validSentMessageIDs helper. Adds a recipient address matching helper addressesReferToSameRecipient for phone/email comparison, plus a new test suite covering both.

Changes

Sent Thread Validation Filtering

Layer / File(s) Summary
Recipient address matching helper
src/IMessage/Sources/IMessage/Utilities/ThreadID.swift
Adds addressesReferToSameRecipient with a minimumPhoneSuffixDigits threshold to match addresses case-insensitively or by numeric phone suffix.
Per-message validation and filtering
src/IMessage/Sources/IMessage/PlatformAPI.swift
Adds validSentMessageIDs to filter sent rows by thread or recipient match; updates waitForMessageSend to log mismatches, report errors, and proceed with only the valid subset instead of rejecting the whole batch.
Validation and matching tests
src/IMessage/Sources/IMessageTests/SentThreadValidationTests.swift
Adds tests for addressesReferToSameRecipient matching/mismatching rules and validSentMessageIDs filtering across thread match, contact fallback, foreign row dropping, unjoined threads, and empty results.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant PlatformAPI
  participant validSentMessageIDs
  participant Database

  Client->>PlatformAPI: waitForMessageSend(sentRows, targetThread)
  PlatformAPI->>Database: resolve sentThreadIDs
  PlatformAPI->>validSentMessageIDs: filter(rows, sentThreadIDs, targetThread, isSameContact)
  validSentMessageIDs-->>PlatformAPI: valid subset
  alt count mismatch
    PlatformAPI->>PlatformAPI: log hashed thread ids, reportErrorMessage
  end
  alt valid subset empty
    PlatformAPI-->>Client: .boolean(true)
  else
    PlatformAPI->>Database: fetch messages for valid subset
    PlatformAPI->>PlatformAPI: validateLinkedMessageIDs
    PlatformAPI-->>Client: .messages
  end
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly reflects the main change: per-row sent-message validation with logging of validation failures.
Description check ✅ Passed The description matches the changeset and explains the validation and logging behavior changes.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch purav/fix-sent-thread-validation

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

@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: 1

🧹 Nitpick comments (1)
src/IMessage/Sources/IMessage/PlatformAPI.swift (1)

748-755: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Error report fires on the benign cases per-message filtering is meant to tolerate.

reportErrorMessage is called whenever validSentMessageIDs.count < sentMessageIDs.count, which includes a dropped foreign row from a concurrent send on another device, and rows that were still unjoined when waitForSentThreadIDs timed out. Those are exactly the scenarios this per-message filtering was introduced to handle gracefully, so reporting them as "validation failed" may generate misleading Sentry noise even when the targeted send succeeded.

If the intent is to surface only genuine misdeliveries, consider gating the report on validSentMessageIDs.isEmpty (or otherwise distinguishing "no valid row for the target" from "some foreign/unjoined rows dropped"). If the noisy report is intentional telemetry for tracking DESK-28166 frequency, this can be left as-is.

🤖 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 `@src/IMessage/Sources/IMessage/PlatformAPI.swift` around lines 748 - 755,
`PlatformAPI` is reporting an error for any partial mismatch between
`sentMessageIDs` and `validSentMessageIDs`, which includes benign filtered-out
foreign or unjoined rows. Update the logic around the `validSentMessageIDs.count
< sentMessageIDs.count` check so `reportErrorMessage` only fires for genuine
misdelivery cases, such as when `validSentMessageIDs.isEmpty` or when the target
thread itself has no valid row; keep the existing `platformLog.error` if you
still want telemetry, but make the `reportErrorMessage` call in
`PlatformAPI.swift` distinguish benign per-message filtering from actual
validation failure.
🤖 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 `@src/IMessage/Sources/IMessageTests/SentThreadValidationTests.swift`:
- Line 4: SwiftLint is flagging the test declarations because the `@Test`
attribute is attached to the function signature on the same line. Update each
affected test in SentThreadValidationTests by moving `@Test` onto its own line
directly above the corresponding func declaration, including
addressesReferToSameRecipientMatchesFormattingVariants and the other flagged
test methods, so the attributes rule is satisfied.

---

Nitpick comments:
In `@src/IMessage/Sources/IMessage/PlatformAPI.swift`:
- Around line 748-755: `PlatformAPI` is reporting an error for any partial
mismatch between `sentMessageIDs` and `validSentMessageIDs`, which includes
benign filtered-out foreign or unjoined rows. Update the logic around the
`validSentMessageIDs.count < sentMessageIDs.count` check so `reportErrorMessage`
only fires for genuine misdelivery cases, such as when
`validSentMessageIDs.isEmpty` or when the target thread itself has no valid row;
keep the existing `platformLog.error` if you still want telemetry, but make the
`reportErrorMessage` call in `PlatformAPI.swift` distinguish benign per-message
filtering from actual validation failure.
🪄 Autofix (Beta)

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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2acf7f18-a273-4501-8ea5-6611786799ce

📥 Commits

Reviewing files that changed from the base of the PR and between 2d4c88d and 1ef170e.

📒 Files selected for processing (3)
  • src/IMessage/Sources/IMessage/PlatformAPI.swift
  • src/IMessage/Sources/IMessage/Utilities/ThreadID.swift
  • src/IMessage/Sources/IMessageTests/SentThreadValidationTests.swift
📜 Review details
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-05-03T17:00:19.662Z
Learnt from: KishanBagaria
Repo: beeper/platform-imessage PR: 69
File: src/IMessage/Sources/IMessage/EventWatcher/EventWatcher+Updates.swift:89-93
Timestamp: 2026-05-03T17:00:19.662Z
Learning: In the beeper/platform-imessage Swift codebase, keep message IDs (`PlatformSDK.MessageID`) as raw GUIDs. When mapping from DB/event rows to `message.id`, set the ID directly from `msgRow.guid` (no GUID→public-ID hashing or transformation). For multi-part messages, append the part index as `_<part.index>` to the GUID-derived ID. During code review, if changes touch message ID creation/mapping, ensure this raw GUID + optional `_<part.index>` suffix behavior is preserved.

Applied to files:

  • src/IMessage/Sources/IMessageTests/SentThreadValidationTests.swift
  • src/IMessage/Sources/IMessage/Utilities/ThreadID.swift
  • src/IMessage/Sources/IMessage/PlatformAPI.swift
🪛 SwiftLint (0.65.0)
src/IMessage/Sources/IMessageTests/SentThreadValidationTests.swift

[Warning] 4-4: Attributes should be on their own lines in functions and types, but on the same line as variables and imports

(attributes)


[Warning] 11-11: Attributes should be on their own lines in functions and types, but on the same line as variables and imports

(attributes)


[Warning] 25-25: Attributes should be on their own lines in functions and types, but on the same line as variables and imports

(attributes)


[Warning] 35-35: Attributes should be on their own lines in functions and types, but on the same line as variables and imports

(attributes)


[Warning] 48-48: Attributes should be on their own lines in functions and types, but on the same line as variables and imports

(attributes)


[Warning] 58-58: Attributes should be on their own lines in functions and types, but on the same line as variables and imports

(attributes)


[Warning] 69-69: Attributes should be on their own lines in functions and types, but on the same line as variables and imports

(attributes)


[Warning] 79-79: Attributes should be on their own lines in functions and types, but on the same line as variables and imports

(attributes)

🔇 Additional comments (2)
src/IMessage/Sources/IMessage/Utilities/ThreadID.swift (1)

26-44: LGTM!

src/IMessage/Sources/IMessage/PlatformAPI.swift (1)

766-785: LGTM!

@testable import IMessage
import Testing

@Test func addressesReferToSameRecipientMatchesFormattingVariants() {

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Move @Test onto its own line to clear the SwiftLint attributes warnings.

SwiftLint flags all eight test declarations because the attributes rule requires attributes to be on their own line for functions. Placing @Test on the line above each func resolves the warnings.

Example fix (apply to each flagged declaration)
-@Test func addressesReferToSameRecipientMatchesFormattingVariants() {
+@Test
+func addressesReferToSameRecipientMatchesFormattingVariants() {

Also applies to: 11-11, 25-25, 35-35, 48-48, 58-58, 69-69, 79-79

🧰 Tools
🪛 SwiftLint (0.65.0)

[Warning] 4-4: Attributes should be on their own lines in functions and types, but on the same line as variables and imports

(attributes)

🤖 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 `@src/IMessage/Sources/IMessageTests/SentThreadValidationTests.swift` at line
4, SwiftLint is flagging the test declarations because the `@Test` attribute is
attached to the function signature on the same line. Update each affected test
in SentThreadValidationTests by moving `@Test` onto its own line directly above
the corresponding func declaration, including
addressesReferToSameRecipientMatchesFormattingVariants and the other flagged
test methods, so the attributes rule is satisfied.

Source: Linters/SAST tools

let digitsA = a.filter(\.isNumber)
let digitsB = b.filter(\.isNumber)
guard min(digitsA.count, digitsB.count) >= minimumPhoneSuffixDigits else { return false }
return digitsA.hasSuffix(digitsB) || digitsB.hasSuffix(digitsA)

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.

Latent: digit-only comparison can alias group chat GUIDs with 1-1 phone numbers

threadIDToAddress returns chat<digits> for group threads (iMessage;+;chat123…) and a phone/email for 1-1s. Because this helper strips non-digits before the suffix check, a group's chat15035550123 and the 1-1 address +15035550123 produce identical digit strings and match. The earlier sentThreadID == targetThreadID short-circuit in validSentMessageIDs won't fire because the full thread IDs differ (+ vs - type token), so we'd fall through to this helper and reconcile a foreign group row as ours (or vice versa).

Real Messages chat GUIDs are long and effectively random, so the practical collision odds are tiny, but the helper accepts any string pair and doesn't distinguish thread type. If you want to defensively gate it, consider bailing when the input looks like a group chat GUID (e.g., hasPrefix("chat")) or comparing thread types before calling into this helper.

.map { $0.map { Hasher.thread.tokenizeRemembering(pii: $0) } ?? "<unjoined>" }
.joined(separator: " ")
platformLog.error("imsg: imessage potentially sent messages to invalid thread (target=\(hashedTarget) sent=\(hashedSent))")
reportErrorMessage("imessage sent message thread validation failed for \(sentMessageIDs.count - validSentMessageIDs.count)/\(sentMessageIDs.count) messages")

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.

Nit: Sentry will fire for legitimate concurrent multi-device sends

waitForSentMessageIDs matches by text and rowID > lastRowID, so a second Apple device sending the same body around the same time will produce a foreign row that we now correctly drop — but this branch will still reportErrorMessage a validation failed count to Sentry.

Per the commit message this reporting is intentional ("so future rageshakes show which mismatch flavor occurred"). Just calling it out in case the Sentry volume from multi-device users turns out noisier than expected — you may want to keep the hashed log line but demote the Sentry report to a rarer trigger (e.g., only when no row validated, or throttled).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant