Skip to content

fix(chat): recover a message left sending by an app kill - #1511

Merged
bmc08gt merged 1 commit into
code/cashfrom
fix/chat-interrupted-send-recovery
Sep 21, 2026
Merged

bmc08gt merged 1 commit into
code/cashfrom
fix/chat-interrupted-send-recovery

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

Kill the app while a chat message is sending and the row is stranded. MessagingDelegate marks a send failed only from the onFailure of the coroutine that issued it, and there is no startup hook, so a dead process leaves status = 'SENDING' on disk with nothing that will ever move it.

What the user sees is a bubble stuck on the sending spinner — retry is offered for FAILED, not SENDING — until some later refresh calls upsertAndClearPending, which deletes the row. The text goes with it, and nothing offers a way back.

The fix

deleteAllPending is scoped to one status:

DELETE FROM chat_messages WHERE chat_id_hex = :chatIdHex AND status = 'SENDING'

FAILED rows are spared. So flipping the orphans to FAILED once at startup is enough to make them survive every later refresh, and they then pick up the retry affordance that already exists. retryPending reuses the stored pending_client_id_hex — the same client_message_id the server dedupes on — so the re-send replaces the original rather than duplicating it.

Three additive changes: a DAO query, a data-source pass-through, and MessagingDelegate.recoverInterruptedSends() called from RealChatCoordinator.onUserLoggedIn.

Why it is awaited rather than launched

The sibling delegates take an initialize(scope) hook, and matching them was the obvious shape. It does not work here. Those hooks are field assignments, and syncFeeds() is itself a launch — a launched sweep would race the refresh it has to precede. The order is not a preference: the sweep either runs first and makes the row durable, or runs second and finds nothing. So it is suspend and awaited inline.

The path it depends on, end to end:

UserManager.set(AuthState.Ready) → Events.OnLoggedIn (fires only on the transition into Ready) → InternalEventRepository.notifyListeners → RealChatCoordinator.onUserLoggedIn → sweep → syncFeeds(). AuthManager.login opens the database before userManager.establish/set, so the nullable db?. in ChatMessageDataSource cannot make the sweep silently no-op.

Tests

Three DAO cases in ChatMessageDaoTest: a SENDING row becomes FAILED, that row then survives upsertAndClearPending for its chat, and a SENT row is untouched.

InterruptedSendRecoveryTest pins the order at the coordinator. It was checked in both directions — moving the call after syncFeeds() fails it. That mattered: the first version of this test used coVerifyOrder and passed against the broken order, because the refresh is a launch and so could not have started before a sweep that never suspends had already returned. The shipped version models the sweep's suspension point, which is what makes it able to tell the two apart.

Not in scope

Two real bugs in the same code, left out because they change refresh logic rather than add to it:

  • getPendingClientIds has no ORDER BY, and the merge pairs results positionally with rescuedIds.removeAt(0) against any self-authored entity in the page. Concurrent sends can cross ids, and a message sent from another device can be handed a local pending id.
  • upsertAndClearPending deletes every SENDING row in the chat whether or not it was confirmed, so a send in flight when a refresh arrives still vanishes. This change reduces that window but does not close it.

Auto-retrying the swept rows instead of waiting for a tap is also left out. It is worth doing, but it is a behaviour change rather than a data-loss fix.

Kill the app while a chat message is sending and the row is stranded. A send is
marked failed only from the `onFailure` of the coroutine that issued it, so a
dead process leaves `status = 'SENDING'` on disk with nothing left to move it.
The bubble spins — retry is offered for `FAILED`, not `SENDING` — until the next
refresh reaches `upsertAndClearPending` and deletes the row, taking the user's
text with it and offering no way back.

`deleteAllPending` is scoped to `SENDING` and spares `FAILED`, so flipping the
orphans once at startup is enough to make them survive every later refresh, at
which point they pick up the retry affordance that already exists. The retry
re-sends under the stored `pending_client_id_hex`, the same `client_message_id`
the server dedupes on, so it replaces the original rather than duplicating it.

The sweep is awaited in `onUserLoggedIn` rather than launched from an
`initialize(scope)` hook like the sibling delegates'. Those hooks are field
assignments, and the refresh is itself a `launch`; a launched sweep would race
the very refresh it has to precede, and a sweep that lands second finds nothing
to do.

Covered by DAO tests for the three states that matter — swept, then surviving
the refresh that would have deleted it, and a sent message left alone — and by a
coordinator test pinning the order. The ordering test was checked in both
directions: moving the call after `syncFeeds()` fails it.
@bmc08gt bmc08gt self-assigned this Sep 20, 2026
@github-actions github-actions Bot added the type: fix Bug fix label Sep 20, 2026
@bmc08gt
bmc08gt merged commit 809079d into code/cash Sep 21, 2026
3 checks passed
@bmc08gt
bmc08gt deleted the fix/chat-interrupted-send-recovery branch September 23, 2026 16:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant