Skip to content

fix: rethrow CancellationException in AnnounceStreamViewModel#613

Merged
torlando-tech merged 2 commits intomainfrom
fix/608-cancellation-exception-announce-stream
Mar 7, 2026
Merged

fix: rethrow CancellationException in AnnounceStreamViewModel#613
torlando-tech merged 2 commits intomainfrom
fix/608-cancellation-exception-announce-stream

Conversation

@torlando-tech
Copy link
Copy Markdown
Owner

Summary

Fixes #608

  • Add catch (e: CancellationException) { throw e } before all 9 catch (e: Exception) blocks in AnnounceStreamViewModel, matching the established codebase convention (TelemetryCollectorManager, MessagingViewModel, SettingsViewModel, EventHandler, OfflineMapDownloadViewModel)
  • The while(true) reachable-count polling loop was the most dangerous — it kept running after viewModelScope cancellation, producing misleading "Job was canceled" error logs and holding references to dead state
  • Add TODO on onCleared() noting that viewModelScope is already cancelled there (separate issue)

Test plan

  • compileNoSentryDebugKotlin builds successfully
  • All 35 AnnounceStreamViewModelTest tests pass (34 existing + 1 new)
  • New test reachable count loop stops when viewModelScope is cancelled verifies the loop terminates on scope cancellation
  • Manual: run app, navigate to Announce Stream, leave for a while, check logcat — no more "Error updating reachable announce count" with "Job was canceled"

🤖 Generated with Claude Code

Fixes #608. All 9 `catch (e: Exception)` blocks were swallowing
CancellationException, breaking structured concurrency. The while(true)
reachable-count loop was especially dangerous — it kept running after
viewModelScope cancellation, producing misleading "Job was canceled"
error logs and holding references to dead ViewModel state.

Add `catch (e: CancellationException) { throw e }` before each
`catch (e: Exception)` block, matching the established codebase
convention used in TelemetryCollectorManager, MessagingViewModel,
SettingsViewModel, EventHandler, and OfflineMapDownloadViewModel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 7, 2026

Greptile Summary

This PR fixes a coroutine-cancellation leak in AnnounceStreamViewModel by inserting catch (e: CancellationException) { throw e } guards before every broad catch (e: Exception) block — matching the established pattern used elsewhere in the codebase (TelemetryCollectorManager, MessagingViewModel, SettingsViewModel, etc.). The most impactful fix is in the while(true) reachable-count polling loop, which previously continued executing and logging spurious "Job was canceled" errors after viewModelScope was cancelled.

  • 9 catch (e: Exception) blocks across updateReachableCount, startCollectingAnnouncesWhenReady, startCollectingAnnounces, toggleContact, triggerAnnounce, setAsMyRelay, unsetRelayAndDelete, deleteAnnounce, and deleteAllAnnounces now all correctly rethrow CancellationException.
  • New test reachable count loop stops when viewModelScope is cancelled validates the loop terminates on scope cancellation using advanceTimeBy + runCurrent (correctly avoids advanceUntilIdle() which would hang on the infinite loop).
  • TODO comment on onCleared() notes that the viewModelScope.launch { reticulumProtocol.shutdown() } call there is likely ineffective — though the comment's stated reason ("viewModelScope is already cancelled here") is slightly inaccurate: the scope is cancelled after onCleared() returns, not before, meaning the launch succeeds but the coroutine is cancelled before shutdown() can execute.

Confidence Score: 5/5

  • Safe to merge — the fix is a well-established Kotlin coroutines pattern, the change is surgical and low-risk, and the new test validates the most critical path.
  • All 9 catch blocks are consistently updated, the fix matches the existing codebase convention, the new test is sound, and there are no logic regressions. The only minor issue is a slightly inaccurate TODO comment wording which does not affect runtime behaviour.
  • No files require special attention; the TODO on onCleared() is worth following up but is a pre-existing concern unrelated to the correctness of this PR.

Important Files Changed

Filename Overview
app/src/main/java/com/lxmf/messenger/viewmodel/AnnounceStreamViewModel.kt Adds catch (e: CancellationException) { throw e } before all 9 catch (e: Exception) blocks to prevent coroutine cancellation from being silently swallowed; also adds a TODO on onCleared() whose wording slightly mischaracterizes the viewModelScope lifecycle.
app/src/test/java/com/lxmf/messenger/viewmodel/AnnounceStreamViewModelTest.kt Adds one new test that verifies the while(true) reachable-count polling loop terminates when viewModelScope is cancelled; test is well-structured with correct use of advanceTimeBy + runCurrent to avoid hanging on the infinite loop.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[viewModelScope launched coroutine] --> B{try block executes}
    B -->|Normal completion| C[Continue / next iteration]
    B -->|CancellationException thrown| D["catch (e: CancellationException)\n{ throw e }  ← NEW"]
    D --> E[Exception propagates up to launch]
    E --> F[Coroutine / loop terminates cleanly]
    B -->|Other Exception thrown| G["catch (e: Exception)\n{ Log.e(...) }"]
    G --> C
    style D fill:#90ee90,stroke:#228B22
    style F fill:#90ee90,stroke:#228B22
Loading

Last reviewed commit: 2e49126

Comment on lines +547 to +548
// TODO: viewModelScope is already cancelled here; shutdown is managed
// by ColumbaApplication. Consider removing or moving to a separate scope.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inaccurate TODO comment about viewModelScope lifecycle

The comment states "viewModelScope is already cancelled here", but this is not accurate. viewModelScope is backed by a CloseableCoroutineScope that is registered as a Closeable on the ViewModel. Android's ViewModel.clear() calls onCleared() first, and only cancels registered closeables (including viewModelScope) after onCleared() returns. So viewModelScope.launch { reticulumProtocol.shutdown() } on line 552 does successfully enqueue a new coroutine — but the scope is cancelled almost immediately after onCleared() returns, creating a race condition where shutdown() is very unlikely to actually execute.

The real concern is better described as: the coroutine launched here will likely be cancelled before reticulumProtocol.shutdown() gets a chance to run. The suggestion to remove or move it to a separate scope is correct, but the stated reason is misleading.

Suggested change
// TODO: viewModelScope is already cancelled here; shutdown is managed
// by ColumbaApplication. Consider removing or moving to a separate scope.
// TODO: viewModelScope is cancelled shortly after onCleared() returns (as a
// registered Closeable), so the coroutine launched below is likely cancelled
// before reticulumProtocol.shutdown() executes. Shutdown is managed by
// ColumbaApplication. Consider removing or moving to a separate scope.
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/viewmodel/AnnounceStreamViewModel.kt
Line: 547-548

Comment:
**Inaccurate TODO comment about `viewModelScope` lifecycle**

The comment states "viewModelScope is already cancelled here", but this is not accurate. `viewModelScope` is backed by a `CloseableCoroutineScope` that is registered as a `Closeable` on the `ViewModel`. Android's `ViewModel.clear()` calls `onCleared()` **first**, and only cancels registered closeables (including `viewModelScope`) **after** `onCleared()` returns. So `viewModelScope.launch { reticulumProtocol.shutdown() }` on line 552 does successfully enqueue a new coroutine — but the scope is cancelled almost immediately after `onCleared()` returns, creating a race condition where `shutdown()` is very unlikely to actually execute.

The real concern is better described as: the coroutine launched here will likely be cancelled before `reticulumProtocol.shutdown()` gets a chance to run. The suggestion to remove or move it to a separate scope is correct, but the stated reason is misleading.

```suggestion
        // TODO: viewModelScope is cancelled shortly after onCleared() returns (as a
        // registered Closeable), so the coroutine launched below is likely cancelled
        // before reticulumProtocol.shutdown() executes. Shutdown is managed by
        // ColumbaApplication. Consider removing or moving to a separate scope.
```

How can I resolve this? If you propose a fix, please make it concise.

@sentry
Copy link
Copy Markdown
Contributor

sentry bot commented Mar 7, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

viewModelScope is cancelled as a registered Closeable *after*
onCleared() returns, not before — so the launch succeeds but the
coroutine is likely cancelled before shutdown() executes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@torlando-tech torlando-tech merged commit 4b25192 into main Mar 7, 2026
13 checks passed
@torlando-tech torlando-tech deleted the fix/608-cancellation-exception-announce-stream branch March 7, 2026 02:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

reachableAnnounceCount is throwing errors about not able to update the announce count

1 participant