[iOS · Delivery Status] Message remains "Not sent" with "Retry" after a read receipt is received #20

Open
opened 2026-11-07 08:55:13 +00:00 by fmodf · 1 comment

Moved from another-im/anotherim-swift#29, reported by @asemeniv on 2026-11-06. The attachments are re-uploaded below.

[iOS · Delivery Status] Message remains "Not sent" with "Retry" after a read receipt is received
Status: Confirmed behavior defect.
Component: Swift delivery model / failure-state UI.
Related investigation: INVESTIGATION_96_offline_queue_rejection_then_delivery.zip

Summary
A message can receive a delivery failure (resource-constraint) and later be successfully received and read by the recipient. However, after the sender receives remote_status=read for the same message, the UI still shows "Not sent" and "Retry".

This creates an inconsistent state: the recipient has read the message, but the sender is still encouraged to resend it.

Environment
A (sender): iPhone 13 Pro Max, iOS 26.7.1, testman421@another.im
B (recipient): iPhone 17 Pro Max, iOS 27.2, testman435@another.im
App: v1.1.0 build 96 (feat/ui-redesign@e945d7cc)
libtacky: v1.2.0-rc4
1:1 OMEMO chat
Steps to reproduce
Send a message from A to B while B is suspended/locked.
The message receives a resource-constraint failure indicating that the offline message queue is full.
Resume B, open the chat, and read the message.
Verify that A receives remote_status=read for the same message ID.
Check the message status on A.
Expected result
Once the same message has been received and read, the UI should show a final state consistent with the actual delivery. A previous delivery-stage failure should not leave the message marked as "Not sent" with an unconditional "Retry" action.

Actual result
Both affected messages received remote_status=read at 15:55:06 UTC, and the messages were present on B. However, at 15:58 UTC, A still displayed both messages in red as "Not sent" with "Retry".

No retry was performed during this test.

Relevant diagnostics
For both affected messages:

A recorded server_status=failed with fail_reason=resource-constraint.
The failure referred to the same message IDs/timestamps that later received remote_status=read.
B received both messages after reconnecting and later marked the conversation as displayed.
Code observation
In revision e945d7cc:

DeliveryChange.applied preserves the previous failed server state when an update contains only a remote status.
TranscriptWindow clears failReason only when delivery.server is updated.
As a result, a read update without a new server status does not clear the previous failure state in the current transcript.

This behavior is consistent with the observed logs and UI, although an independent reproduction and diagnostic stack trace are not available.

Retest criteria
Reproduce a failure followed by successful delivery and read for the same message ID.
Verify that the final UI state no longer shows the stale "Not sent" / "Retry" state.
Repeat after navigating away and back and after restarting the app.
Verify that genuinely undelivered messages without a receipt still retain the appropriate failure and retry behavior.


Analysis (anotherim-swift, 2026-11-07)

The row's state comes from libtacky, and the app shows what it stores:

  • A bounce within BounceWindowUs (10 min) of the send fails the row even after our server's ack (message.tcl :310, :1510-1534); markFailed sets server_status='failed', fail_reason=<condition> (messagestore.tcl :3162).
  • A receipt or a read marker then moves only remote_status (markRemoteStatus, messagestore.tcl :1047-1083), and message <Status> carries only -remote_status (message.tcl :1895). So the row stays failed with remote_status=read, live and after a relaunch.
  • The app cannot simply let "read" beat "failed": a read marker covers every earlier send of ours, failed ones included (the read walk in markRemoteStatus has no server_status filter), so a message that never left (encrypt, upload, too large) would show as read once the peer reads a later one.

Proposed fix in libtacky

  1. markRemoteStatus: the read walk skips rows with server_status='failed', except the row the receipt/marker names.
  2. When that named row is failed (a bounce, not encrypt/upload/too-large), clear server_status to '' (or sent) and fail_reason/fail_text, and emit <Status -server_status … -remote_status …>.
  3. MarkOutgoingFailed on an acked row: ignore the bounce when remote_status != 'none' (receipt first, bounce second; see another-im/anotherim-server#3).
  4. Optionally migrate stored rows that are failed with a bounce condition and remote_status in received/displayed/read.

The app needs no change once this lands: a <Status> with a server status already clears the failure and the Retry (TranscriptWindow.swift). The related server side (why the bounce came at all) is another-im/anotherim-server#3.

> Moved from another-im/anotherim-swift#29, reported by @asemeniv on 2026-11-06. The attachments are re-uploaded below. [iOS · Delivery Status] Message remains "Not sent" with "Retry" after a read receipt is received Status: Confirmed behavior defect. Component: Swift delivery model / failure-state UI. Related investigation: INVESTIGATION_96_offline_queue_rejection_then_delivery.zip Summary A message can receive a delivery failure (resource-constraint) and later be successfully received and read by the recipient. However, after the sender receives remote_status=read for the same message, the UI still shows "Not sent" and "Retry". This creates an inconsistent state: the recipient has read the message, but the sender is still encouraged to resend it. Environment A (sender): iPhone 13 Pro Max, iOS 26.7.1, testman421@another.im B (recipient): iPhone 17 Pro Max, iOS 27.2, testman435@another.im App: v1.1.0 build 96 (feat/ui-redesign@e945d7cc) libtacky: v1.2.0-rc4 1:1 OMEMO chat Steps to reproduce Send a message from A to B while B is suspended/locked. The message receives a resource-constraint failure indicating that the offline message queue is full. Resume B, open the chat, and read the message. Verify that A receives remote_status=read for the same message ID. Check the message status on A. Expected result Once the same message has been received and read, the UI should show a final state consistent with the actual delivery. A previous delivery-stage failure should not leave the message marked as "Not sent" with an unconditional "Retry" action. Actual result Both affected messages received remote_status=read at 15:55:06 UTC, and the messages were present on B. However, at 15:58 UTC, A still displayed both messages in red as "Not sent" with "Retry". No retry was performed during this test. Relevant diagnostics For both affected messages: A recorded server_status=failed with fail_reason=resource-constraint. The failure referred to the same message IDs/timestamps that later received remote_status=read. B received both messages after reconnecting and later marked the conversation as displayed. Code observation In revision e945d7cc: DeliveryChange.applied preserves the previous failed server state when an update contains only a remote status. TranscriptWindow clears failReason only when delivery.server is updated. As a result, a read update without a new server status does not clear the previous failure state in the current transcript. This behavior is consistent with the observed logs and UI, although an independent reproduction and diagnostic stack trace are not available. Retest criteria Reproduce a failure followed by successful delivery and read for the same message ID. Verify that the final UI state no longer shows the stale "Not sent" / "Retry" state. Repeat after navigating away and back and after restarting the app. Verify that genuinely undelivered messages without a receipt still retain the appropriate failure and retry behavior. --- ## Analysis (anotherim-swift, 2026-11-07) The row's state comes from libtacky, and the app shows what it stores: - A bounce within `BounceWindowUs` (10 min) of the send fails the row even after our server's ack (`message.tcl` :310, :1510-1534); `markFailed` sets `server_status='failed'`, `fail_reason=<condition>` (`messagestore.tcl` :3162). - A receipt or a read marker then moves only `remote_status` (`markRemoteStatus`, `messagestore.tcl` :1047-1083), and `message <Status>` carries only `-remote_status` (`message.tcl` :1895). So the row stays `failed` with `remote_status=read`, live and after a relaunch. - The app cannot simply let "read" beat "failed": a read marker covers every earlier send of ours, failed ones included (the read walk in `markRemoteStatus` has no `server_status` filter), so a message that never left (encrypt, upload, too large) would show as read once the peer reads a later one. **Proposed fix in libtacky** 1. `markRemoteStatus`: the read walk skips rows with `server_status='failed'`, except the row the receipt/marker names. 2. When that named row is failed (a bounce, not encrypt/upload/too-large), clear `server_status` to `''` (or `sent`) and `fail_reason`/`fail_text`, and emit `<Status -server_status … -remote_status …>`. 3. `MarkOutgoingFailed` on an acked row: ignore the bounce when `remote_status != 'none'` (receipt first, bounce second; see another-im/anotherim-server#3). 4. Optionally migrate stored rows that are `failed` with a bounce condition and `remote_status` in received/displayed/read. The app needs no change once this lands: a `<Status>` with a server status already clears the failure and the Retry (`TranscriptWindow.swift`). The related server side (why the bounce came at all) is another-im/anotherim-server#3.
Owner

Fixed in libtacky v1.2.1 (https://dev.another.im/another-im/libtacky/releases/tag/v1.2.1): a receipt or read marker that names a failed (bounced) row now clears its failure (<Status> sets server_status '' and fail_reason ''). A read marker no longer passes over a failed row without clearing it, and a bounce that arrives after the peer has already receipted the message is ignored instead of marking it failed again. Already-stuck rows clear as soon as a new receipt or marker names them, so nothing needs a manual fix-up.

The bounce itself (why a delivered, read message could be marked failed in the first place) was another-im/anotherim-server#3; see that issue for the server-side fix.

Please check with v1.2.1 or newer.

Fixed in libtacky v1.2.1 (https://dev.another.im/another-im/libtacky/releases/tag/v1.2.1): a receipt or read marker that names a failed (bounced) row now clears its failure (`<Status>` sets `server_status ''` and `fail_reason ''`). A read marker no longer passes over a failed row without clearing it, and a bounce that arrives after the peer has already receipted the message is ignored instead of marking it failed again. Already-stuck rows clear as soon as a new receipt or marker names them, so nothing needs a manual fix-up. The bounce itself (why a delivered, read message could be marked failed in the first place) was another-im/anotherim-server#3; see that issue for the server-side fix. Please check with v1.2.1 or newer.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
another-im/libtacky#20
No description provided.