fix(compose): closing the screen mid-send cancels the send and silently loses the message #492

Open
opened 2026-07-10 19:14:55 +00:00 by JMR-dev · 0 comments
JMR-dev commented 2026-07-10 19:14:55 +00:00 (Migrated from github.com)

Verified finding(s) from the 2026-07-09 whole-repo multi-agent review (independent finder, then adversarial verifier; verdict CONFIRMED).

Triage: above the cut — fix dispatched immediately; this issue tracks the fix to Done.

app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt:373 — high

onExit() during an in-flight send closes the screen (finish()) without saving a draft, which pops the ViewModel and cancels viewModelScope, killing sendMessage before the outbox insert commits — the composed message is silently lost (no outbox row, no draft, no error).

Failure scenario: User attaches a large file (e.g. 100 MB video), taps Send — performSend suspends in MailRepositoryImpl.sendMessage, which spends seconds in copyAttachments before outboxDao.insert. Impatient (or accidental) back-press fires BackHandler -> onExit: sending == true so saveOrDeleteDraft is skipped, finish() emits, the compose destination pops, the ViewModel is cleared and viewModelScope cancels. The insert never executes (runCatching converts the CancellationException to a failure Result whose onFailure updates a dead screen). The mail is in neither the outbox nor drafts and the user believes it was sent — silent mail loss. The 'sending' state machine has a terminal path that ends with neither an outbox row nor a draft.

Verifier justification (CONFIRMED): The claimed hole is real and unguarded. ComposeScreen.kt has an unconditional BackHandler { viewModel.onExit() } and finished.collect { onBack() }; onExit() (ComposeViewModel.kt:369-376) skips saveOrDeleteDraft when sending==true and calls finish(), popping the destination, clearing the ViewModel, and cancelling viewModelScope while trySend's coroutine is still suspended inside MailRepositoryImpl.sendMessage. sendMessage (MailRepositoryImpl.kt:506) does blocking copyAttachments (seconds for a large file) BEFORE outboxDao.insert and sendScheduler.sendNow(), all inside runCatching, which converts the CancellationException raised at the Room suspension points into a failure Result whose onFailure writes to a dead screen. Concrete trigger: compose a message whose last edit is within the 1.5s autosave debounce (so no draft row exists — autosaveDraft at line 392 also skips while sending), attach a large file, tap Send, press back while the send is in flight. Cancellation before the insert leaves no outbox row, no draft, no error — silent loss; cancellation during the insert dispatch commits the row but skips sendNow(), leaving the message stuck (SendWorker is only enqueued via sendNow/retryOutbox — the periodic SyncWorker never drains the outbox). Mitigant honestly noted: in longer compositions an earlier autosaved draft survives (deleted only on send success), so content is often recoverable in Drafts — but the user believes the mail was sent, so it is still silently-unsent mail.

Defective line: // Don't save a draft for a message that's mid-send (send() will finish the screen). if (!_state.value.sending) saveOrDeleteDraft() finish()

Fix hint: Make the outbox enqueue survive ViewModel death: wrap the enqueue path in performSend with withContext(NonCancellable) or launch it in an injected application-scoped CoroutineScope, and stop runCatching from swallowing CancellationException in MailRepositoryImpl.sendMessage. Alternatively (or additionally), have onExit() while sending==true either wait for the in-flight send or fall back to saveOrDeleteDraft() so the message is never dropped on the floor.

Verified finding(s) from the 2026-07-09 whole-repo multi-agent review (independent finder, then adversarial verifier; verdict **CONFIRMED**). **Triage: above the cut — fix dispatched immediately; this issue tracks the fix to Done.** ## `app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt:373` — high onExit() during an in-flight send closes the screen (finish()) without saving a draft, which pops the ViewModel and cancels viewModelScope, killing sendMessage before the outbox insert commits — the composed message is silently lost (no outbox row, no draft, no error). **Failure scenario:** User attaches a large file (e.g. 100 MB video), taps Send — performSend suspends in MailRepositoryImpl.sendMessage, which spends seconds in copyAttachments before outboxDao.insert. Impatient (or accidental) back-press fires BackHandler -> onExit: sending == true so saveOrDeleteDraft is skipped, finish() emits, the compose destination pops, the ViewModel is cleared and viewModelScope cancels. The insert never executes (runCatching converts the CancellationException to a failure Result whose onFailure updates a dead screen). The mail is in neither the outbox nor drafts and the user believes it was sent — silent mail loss. The 'sending' state machine has a terminal path that ends with neither an outbox row nor a draft. **Verifier justification (CONFIRMED):** The claimed hole is real and unguarded. ComposeScreen.kt has an unconditional `BackHandler { viewModel.onExit() }` and `finished.collect { onBack() }`; onExit() (ComposeViewModel.kt:369-376) skips saveOrDeleteDraft when sending==true and calls finish(), popping the destination, clearing the ViewModel, and cancelling viewModelScope while trySend's coroutine is still suspended inside MailRepositoryImpl.sendMessage. sendMessage (MailRepositoryImpl.kt:506) does blocking copyAttachments (seconds for a large file) BEFORE outboxDao.insert and sendScheduler.sendNow(), all inside runCatching, which converts the CancellationException raised at the Room suspension points into a failure Result whose onFailure writes to a dead screen. Concrete trigger: compose a message whose last edit is within the 1.5s autosave debounce (so no draft row exists — autosaveDraft at line 392 also skips while sending), attach a large file, tap Send, press back while the send is in flight. Cancellation before the insert leaves no outbox row, no draft, no error — silent loss; cancellation during the insert dispatch commits the row but skips sendNow(), leaving the message stuck (SendWorker is only enqueued via sendNow/retryOutbox — the periodic SyncWorker never drains the outbox). Mitigant honestly noted: in longer compositions an earlier autosaved draft survives (deleted only on send success), so content is often recoverable in Drafts — but the user believes the mail was sent, so it is still silently-unsent mail. **Defective line:** `// Don't save a draft for a message that's mid-send (send() will finish the screen). if (!_state.value.sending) saveOrDeleteDraft() finish()` **Fix hint:** Make the outbox enqueue survive ViewModel death: wrap the enqueue path in performSend with withContext(NonCancellable) or launch it in an injected application-scoped CoroutineScope, and stop runCatching from swallowing CancellationException in MailRepositoryImpl.sendMessage. Alternatively (or additionally), have onExit() while sending==true either wait for the in-flight send or fall back to saveOrDeleteDraft() so the message is never dropped on the floor.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMail#492