Delivery indicator gets stuck: your last message keeps "Sending..." (or "Queued" / "Failed to send") after the server has it #306

Open
opened 2026-10-04 23:44:34 -04:00 by jared · 1 comment
Owner

The delivery-status indicator on your own messages (DeliveryStatus in src/app/features/room/message/Message.tsx:106, added in 0897e2ce) shows whatever mEvent.status was the last time the timeline happened to re-render. It never re-renders when the send status changes. Nothing re-renders the row after the server confirms the message unless someone reads it, someone else posts, or you send another message. So the last message you send in a quiet room keeps a spinning "Sending..." arrow indefinitely, and it looks unsent.

Repro (Playwright, local Synapse; /send delayed by 100 ms or 300 ms to look like a real network):

  1. Send 10 messages 1.5 s apart in a room where nobody else is active.
  2. Wait 10 s and read each row's indicator.
latency 300 ms: on server 10/10; rows: $· $· $· $· $· $· $· $· $· ~Sending...
latency 100 ms: on server 10/10; rows: $· $· $· $· $· $· $· $· $· ~Sending...

The last row still has its local-echo id (data-message-id="~!room:txn") and the "Sending..." arrow, see the screenshot. It changed only when Bob sent a read receipt or a message, or when the room was opened again. Locally, with Synapse answering in about 10 ms, the status is usually null already by the time the composer re-render happens, which hides the bug in development.

The same stale render produces two worse states:

  • "Failed to send. Click to retry" on a delivered message. The outbox (#112) automatically retries a send that lost the connection; the retry went out after 5 s and the server stored exactly one copy. The row kept the red ✕ "Failed to send. Click to retry" for more than 40 s, until the room was re-opened. Clicking it does nothing, because handleRetry checks mEvent.status === NOT_SENT and the status is now null. A user who believes it is a failure will type the message again, which creates a duplicate.
  • "Sending..." forever after an HTTP 502. The SDK scheduler retried after about 2 s and the message was delivered, but the row showed "Sending..." for the whole 40 s window.
  • "Queued" left behind: with two quick sends, the second message is QUEUED behind the first. It kept "Queued" after both were delivered (8 s later: first "Sending...", second "Queued").

Why: RoomTimeline re-renders on RoomEvent.Timeline. A local echo's later status changes (SENDING → SENT → null when the remote echo arrives, NOT_SENT → resend → null) only emit RoomEvent.LocalEchoUpdated and MatrixEventEvent.Status / LocalEventIdReplaced, and nothing in the timeline listens to those. This is the same root cause as the stale ~ id in #284.

Fix: have DeliveryStatus (and ideally the message row, for data-message-id) subscribe to the event: mEvent.on(MatrixEventEvent.Status, …) and MatrixEventEvent.LocalEventIdReplaced, with a useState tick. Alternatively, bump the timeline on RoomEvent.LocalEchoUpdated for the open room. Add a Playwright test with a delayed /send route that checks the last message's indicator clears within 2 s of the server response, and that a message auto-retried by the outbox no longer shows "Failed to send".

Screenshot

delivery-stale-sending.png


Found in the October 2026 client audit (Playwright against a local Synapse + LiveKit stack, plus code reading). Code refs are at cinny 30e2dbcd, cinny-desktop 8f67ec7, element-call 3b0050e8.

The delivery-status indicator on your own messages (`DeliveryStatus` in `src/app/features/room/message/Message.tsx:106`, added in 0897e2ce) shows whatever `mEvent.status` was the last time the timeline happened to re-render. It never re-renders when the send status changes. Nothing re-renders the row after the server confirms the message unless someone reads it, someone else posts, or you send another message. So the **last message you send in a quiet room keeps a spinning "Sending..." arrow indefinitely**, and it looks unsent. **Repro** (Playwright, local Synapse; `/send` delayed by 100 ms or 300 ms to look like a real network): 1. Send 10 messages 1.5 s apart in a room where nobody else is active. 2. Wait 10 s and read each row's indicator. ``` latency 300 ms: on server 10/10; rows: $· $· $· $· $· $· $· $· $· ~Sending... latency 100 ms: on server 10/10; rows: $· $· $· $· $· $· $· $· $· ~Sending... ``` The last row still has its local-echo id (`data-message-id="~!room:txn"`) and the "Sending..." arrow, see the screenshot. It changed only when Bob sent a read receipt or a message, or when the room was opened again. Locally, with Synapse answering in about 10 ms, the status is usually `null` already by the time the composer re-render happens, which hides the bug in development. **The same stale render produces two worse states:** - **"Failed to send. Click to retry" on a delivered message.** The outbox (#112) automatically retries a send that lost the connection; the retry went out after 5 s and the server stored exactly one copy. The row kept the red ✕ "Failed to send. Click to retry" for more than 40 s, until the room was re-opened. Clicking it does nothing, because `handleRetry` checks `mEvent.status === NOT_SENT` and the status is now `null`. A user who believes it is a failure will type the message again, which creates a duplicate. - **"Sending..." forever after an HTTP 502.** The SDK scheduler retried after about 2 s and the message was delivered, but the row showed "Sending..." for the whole 40 s window. - **"Queued" left behind:** with two quick sends, the second message is `QUEUED` behind the first. It kept "Queued" after both were delivered (8 s later: first "Sending...", second "Queued"). **Why:** `RoomTimeline` re-renders on `RoomEvent.Timeline`. A local echo's later status changes (`SENDING → SENT → null` when the remote echo arrives, `NOT_SENT` → resend → `null`) only emit `RoomEvent.LocalEchoUpdated` and `MatrixEventEvent.Status` / `LocalEventIdReplaced`, and nothing in the timeline listens to those. This is the same root cause as the stale `~` id in #284. **Fix:** have `DeliveryStatus` (and ideally the message row, for `data-message-id`) subscribe to the event: `mEvent.on(MatrixEventEvent.Status, …)` and `MatrixEventEvent.LocalEventIdReplaced`, with a `useState` tick. Alternatively, bump the timeline on `RoomEvent.LocalEchoUpdated` for the open room. Add a Playwright test with a delayed `/send` route that checks the last message's indicator clears within 2 s of the server response, and that a message auto-retried by the outbox no longer shows "Failed to send". **Screenshot** ![delivery-stale-sending.png](https://code.lotusguild.org/attachments/7380cedf-a470-4e8b-80e4-a0bedb64bd47) --- _Found in the October 2026 client audit (Playwright against a local Synapse + LiveKit stack, plus code reading). Code refs are at cinny `30e2dbcd`, cinny-desktop `8f67ec7`, element-call `3b0050e8`._
jared added the bugpriority: mediumarea: messaging labels 2026-10-04 23:44:34 -04:00
Author
Owner

Realistic repro (2026-10-05): freeze the dev Synapse for 90 s (kill -STOP), send a message during the stall, then resume (kill -CONT). The server stores the message within 1 s of resuming (one copy), but the row keeps the "Sending..." arrow for at least 21 s afterwards and only clears on the next re-render. After a real outage, users will think their last message never went out.

Realistic repro (2026-10-05): freeze the dev Synapse for 90 s (`kill -STOP`), send a message during the stall, then resume (`kill -CONT`). The server stores the message within 1 s of resuming (one copy), but the row keeps the "Sending..." arrow for at least 21 s afterwards and only clears on the next re-render. After a real outage, users will think their last message never went out.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: LotusGuild/cinny#306