Repository navigation
fix(chat): recover a message left sending by an app kill - #1511
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Kill the app while a chat message is sending and the row is stranded.
MessagingDelegatemarks a send failed only from theonFailureof the coroutine that issued it, and there is no startup hook, so a dead process leavesstatus = '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, notSENDING— until some later refresh callsupsertAndClearPending, which deletes the row. The text goes with it, and nothing offers a way back.The fix
deleteAllPendingis scoped to one status:FAILEDrows are spared. So flipping the orphans toFAILEDonce at startup is enough to make them survive every later refresh, and they then pick up the retry affordance that already exists.retryPendingreuses the storedpending_client_id_hex— the sameclient_message_idthe 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 fromRealChatCoordinator.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, andsyncFeeds()is itself alaunch— 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 issuspendand awaited inline.The path it depends on, end to end:
UserManager.set(AuthState.Ready)→Events.OnLoggedIn(fires only on the transition intoReady) →InternalEventRepository.notifyListeners→RealChatCoordinator.onUserLoggedIn→ sweep →syncFeeds().AuthManager.loginopens the database beforeuserManager.establish/set, so the nullabledb?.inChatMessageDataSourcecannot make the sweep silently no-op.Tests
Three DAO cases in
ChatMessageDaoTest: aSENDINGrow becomesFAILED, that row then survivesupsertAndClearPendingfor its chat, and aSENTrow is untouched.InterruptedSendRecoveryTestpins the order at the coordinator. It was checked in both directions — moving the call aftersyncFeeds()fails it. That mattered: the first version of this test usedcoVerifyOrderand passed against the broken order, because the refresh is alaunchand 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:
getPendingClientIdshas noORDER BY, and the merge pairs results positionally withrescuedIds.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.upsertAndClearPendingdeletes everySENDINGrow 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.