Application scope has no CoroutineExceptionHandler — a Room flow throw kills headless processes #45

Closed
opened 2026-08-19 02:52:58 -05:00 by null · 1 comment
Owner

The app-wide coroutine scope has no CoroutineExceptionHandler, so a throw from either Room flow it collects kills the process — in the background, with no UI, at every boot.

What is true now

app/src/main/kotlin/dev/privacyllc/period/PeriodApplication.kt builds the scope that outlives every screen:

private val applicationScope =
    CoroutineScope(SupervisorJob() + Dispatchers.Default)

override fun onCreate() {
    super.onCreate()
    reminderCoordinator.start(applicationScope)
}

ReminderCoordinator.start() then launchIns two Room flows on it.

SupervisorJob is the trap. It stops a failing child cancelling its siblings — it does not stop the exception. An uncaught throw inside launch goes to the CoroutineExceptionHandler in the scope's context, and with none present it reaches the thread's default handler and takes the process down.

Both ViewModels already do this correctly. PrivacyViewModel.kt:65 installs a handler and its KDoc names the lesson: "Batch 01's lesson, learned by tapping a button twice: a repository call that throws inside viewModelScope.launch kills the app." TodayViewModel has one too. The one scope that runs with no user present is the one without.

The concrete trigger

This is not hypothetical. repository.forecast runs the prediction engine inside the flow — it combines confirmed periods, "not yet" observations and scored errors, then calls engine.predict(...). Prediction's init block enforces its invariants with require, so an engine result where the window is inverted or the most-likely date falls outside it throws IllegalArgumentException directly into an unhandled collection.

Why the blast radius is larger than it looks

The scope is started from Application.onCreate, which runs in every process — including ones WorkManager starts with no Activity anywhere:

  • RescheduleReceiver after a reboot (RECEIVE_BOOT_COMPLETED, which the permission allowlist grants for exactly this)
  • SystemJobService when the daily reminder fires
  • ForceStopRunnable$BroadcastReceiver

So the failure mode is a process that dies moments after boot, repeatedly, with nothing on screen to explain it and no crash reporter configured (SECURITY.md's third-parties table records that decision as still open). The user's symptom is reminders that stopped, with no error.

Also worth fixing while here: ReminderWorker.doWork() has no try/catch. A throw becomes Result.failure(), which for periodic work means that run is simply lost — silent, and indistinguishable from having nothing to say.

What to do

  1. Install a CoroutineExceptionHandler on the application scope in PeriodApplication.kt.
  2. .catch { } on both flow chains in ReminderCoordinator.start() so a repository failure degrades to "no reminder" rather than killing the process.
  3. ReminderWorker.doWork() returns Result.success() and posts nothing when it cannot read what it needs, so a data failure is not mistaken for a transient one.

The handler must not log. checkNoHealthLogging forbids logging calls in app/**, and an exception message here can carry a health-derived value — which is exactly the leak NoDatesInDiagnosticsTest exists to prevent.

Why filed separately

Found while researching database encryption, where an unopenable database would have made this fire on every boot. It is a defect in its own right and predates that work.

Verify: a test that makes a repository flow throw and asserts the application scope survives and the process is not torn down; the test must fail with the handler removed.

The app-wide coroutine scope has no `CoroutineExceptionHandler`, so a throw from either Room flow it collects kills the process — in the background, with no UI, at every boot. ## What is true now `app/src/main/kotlin/dev/privacyllc/period/PeriodApplication.kt` builds the scope that outlives every screen: ```kotlin private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) override fun onCreate() { super.onCreate() reminderCoordinator.start(applicationScope) } ``` `ReminderCoordinator.start()` then `launchIn`s **two** Room flows on it. `SupervisorJob` is the trap. It stops a failing child cancelling its siblings — it does **not** stop the exception. An uncaught throw inside `launch` goes to the `CoroutineExceptionHandler` in the scope's context, and with none present it reaches the thread's default handler and takes the process down. Both ViewModels already do this correctly. `PrivacyViewModel.kt:65` installs a handler and its KDoc names the lesson: *"Batch 01's lesson, learned by tapping a button twice: a repository call that throws inside `viewModelScope.launch` kills the app."* `TodayViewModel` has one too. The one scope that runs **with no user present** is the one without. ## The concrete trigger This is not hypothetical. `repository.forecast` runs the prediction engine *inside* the flow — it combines confirmed periods, "not yet" observations and scored errors, then calls `engine.predict(...)`. `Prediction`'s init block enforces its invariants with `require`, so an engine result where the window is inverted or the most-likely date falls outside it throws `IllegalArgumentException` directly into an unhandled collection. ## Why the blast radius is larger than it looks The scope is started from `Application.onCreate`, which runs in **every** process — including ones WorkManager starts with no Activity anywhere: - `RescheduleReceiver` after a reboot (`RECEIVE_BOOT_COMPLETED`, which the permission allowlist grants for exactly this) - `SystemJobService` when the daily reminder fires - `ForceStopRunnable$BroadcastReceiver` So the failure mode is a process that dies moments after boot, repeatedly, with nothing on screen to explain it and no crash reporter configured (`SECURITY.md`'s third-parties table records that decision as still open). The user's symptom is reminders that stopped, with no error. Also worth fixing while here: `ReminderWorker.doWork()` has no `try`/`catch`. A throw becomes `Result.failure()`, which for periodic work means that run is simply lost — silent, and indistinguishable from having nothing to say. ## What to do 1. Install a `CoroutineExceptionHandler` on the application scope in `PeriodApplication.kt`. 2. `.catch { }` on both flow chains in `ReminderCoordinator.start()` so a repository failure degrades to "no reminder" rather than killing the process. 3. `ReminderWorker.doWork()` returns `Result.success()` and posts nothing when it cannot read what it needs, so a data failure is not mistaken for a transient one. **The handler must not log.** `checkNoHealthLogging` forbids logging calls in `app/**`, and an exception message here can carry a health-derived value — which is exactly the leak `NoDatesInDiagnosticsTest` exists to prevent. ## Why filed separately Found while researching database encryption, where an unopenable database would have made this fire on every boot. It is a defect in its own right and predates that work. Verify: a test that makes a repository flow throw and asserts the application scope survives and the process is not torn down; the test must fail with the handler removed.
null added this to the Batch 06 — Privacy and Security milestone 2026-08-19 02:52:58 -05:00
null added the
P1
label 2026-08-19 02:53:33 -05:00
null closed this issue 2026-08-19 03:06:14 -05:00
Author
Owner

Fixed in 424a513.

What changed

File Change
app/.../PeriodApplication.kt the scope moves into applicationScope() and carries a CoroutineExceptionHandler
app/.../notifications/ReminderCoordinator.kt each chain .catch { }es its own failure; chains extracted to internal functions taking flows
core/notifications/.../ReminderWorker.kt doWork wraps decideAndNotify(), returns success and posts nothing on failure, rethrows CancellationException

Evidence

ApplicationScopeTest and ReminderCoordinatorTest, 6 tests, all executed:

ApplicationScopeTest      tests=2 failures=0 errors=0 skipped=0
    a throwing coroutine does not reach the default uncaught handler
    the scope still accepts work after a child has failed
ReminderCoordinatorTest   tests=4 failures=0 errors=0 skipped=0
    a throwing forecast does not escape the schedule chain
    a throwing preferences flow does not escape the schedule chain
    a throwing history flow does not escape the check-in chain
    a healthy flow still reaches the collector

Whole repo after the change: 207 tests, 0 failures, 0 errors, 0 skipped.
checkModuleBoundaries, checkNoHealthLogging and checkPermissions all exit 0,
the last against a freshly built release manifest.

Proved to fail, per GUARDS.md §1

Removing the handler fails exactly one test:

ApplicationScopeTest > a throwing coroutine does not reach the default uncaught handler FAILED
    java.lang.AssertionError at ApplicationScopeTest.kt:69
1 test completed, 1 failed        →  prove-guard exit 0

Removing either .catch { } fails exactly its own test — both also exit 0.

The last test is a positive control: a healthy flow still reaches the collector,
so a catch that swallowed everything could not pass the other three by
accident.

Three things found while doing it

1. prove-guard.sh reported a catch that never happened. The first proof ran
./gradlew :app:test --tests '*ApplicationScopeTest*'. :app:test is AGP's
lifecycle task and takes no --tests option, so Gradle failed with
Unknown command-line option '--tests' in 544 ms — the mutation was never
compiled and the test never ran — and the script reported "the guard caught
it"
. It decides from the runner's exit code and cannot tell a broken test from
a malformed command. The concrete task is :app:testDebugUnitTest.

2. The three documented boundary proofs have never exited 0. architecture/README.md
presents them as run and passing. Following them exactly gives exit 3
"not a pass" — because Gradle prints no test-style summary for that task, so
prove-guard counts log lines and its default pattern also matches FAILURE: and
BUILD FAILED. One caught violation reads as three. All three now carry
PROVE_GUARD_FAIL_PATTERN and were re-run: exit 0, 0, 0.

Both lessons are recorded in docs/architecture/GUARDS.md §8, whose review
trigger — "a guard is found to have been passing while the thing it guards was
broken"
— is exactly this.

3. A stale build directory, unrelated but worth knowing. :app:assembleDebug
failed with a dexing transform pointing at /home/kaspa/.openclaw/Projects/Period/…,
a path that no longer exists — leftover state from before this directory was
renamed. ./gradlew :core:notifications:clean cleared it. Not caused by this
change; it was surfaced by the new class the fix introduced.

Not updated, deliberately

doc-triggers.py fires docs/design/README.md on path. Its trigger is "Any new
user-facing screen or state; any change to the colour or type tokens; any change
to notification copy or to a privacy or fertility disclaimer"
— none of which
occurred. Recorded here rather than skipped silently.

Fixed in `424a513`. ### What changed | File | Change | | --- | --- | | `app/.../PeriodApplication.kt` | the scope moves into `applicationScope()` and carries a `CoroutineExceptionHandler` | | `app/.../notifications/ReminderCoordinator.kt` | each chain `.catch { }`es its own failure; chains extracted to `internal` functions taking flows | | `core/notifications/.../ReminderWorker.kt` | `doWork` wraps `decideAndNotify()`, returns success and posts nothing on failure, rethrows `CancellationException` | ### Evidence `ApplicationScopeTest` and `ReminderCoordinatorTest`, 6 tests, all executed: ``` ApplicationScopeTest tests=2 failures=0 errors=0 skipped=0 a throwing coroutine does not reach the default uncaught handler the scope still accepts work after a child has failed ReminderCoordinatorTest tests=4 failures=0 errors=0 skipped=0 a throwing forecast does not escape the schedule chain a throwing preferences flow does not escape the schedule chain a throwing history flow does not escape the check-in chain a healthy flow still reaches the collector ``` Whole repo after the change: **207 tests, 0 failures, 0 errors, 0 skipped**. `checkModuleBoundaries`, `checkNoHealthLogging` and `checkPermissions` all exit 0, the last against a freshly built **release** manifest. ### Proved to fail, per GUARDS.md §1 Removing the handler fails **exactly one** test: ``` ApplicationScopeTest > a throwing coroutine does not reach the default uncaught handler FAILED java.lang.AssertionError at ApplicationScopeTest.kt:69 1 test completed, 1 failed → prove-guard exit 0 ``` Removing either `.catch { }` fails exactly its own test — both also exit 0. The last test is a positive control: a healthy flow still reaches the collector, so a `catch` that swallowed everything could not pass the other three by accident. ### Three things found while doing it **1. `prove-guard.sh` reported a catch that never happened.** The first proof ran `./gradlew :app:test --tests '*ApplicationScopeTest*'`. `:app:test` is AGP's lifecycle task and takes no `--tests` option, so Gradle failed with `Unknown command-line option '--tests'` in 544 ms — the mutation was never compiled and the test never ran — and the script reported *"the guard caught it"*. It decides from the runner's exit code and cannot tell a broken test from a malformed command. The concrete task is `:app:testDebugUnitTest`. **2. The three documented boundary proofs have never exited 0.** `architecture/README.md` presents them as run and passing. Following them exactly gives **exit 3** — "not a pass" — because Gradle prints no test-style summary for that task, so prove-guard counts log lines and its default pattern also matches `FAILURE:` and `BUILD FAILED`. One caught violation reads as three. All three now carry `PROVE_GUARD_FAIL_PATTERN` and were re-run: **exit 0, 0, 0**. Both lessons are recorded in `docs/architecture/GUARDS.md` §8, whose review trigger — *"a guard is found to have been passing while the thing it guards was broken"* — is exactly this. **3. A stale build directory, unrelated but worth knowing.** `:app:assembleDebug` failed with a dexing transform pointing at `/home/kaspa/.openclaw/Projects/Period/…`, a path that no longer exists — leftover state from before this directory was renamed. `./gradlew :core:notifications:clean` cleared it. Not caused by this change; it was surfaced by the new class the fix introduced. ### Not updated, deliberately `doc-triggers.py` fires `docs/design/README.md` on path. Its trigger is *"Any new user-facing screen or state; any change to the colour or type tokens; any change to notification copy or to a privacy or fertility disclaimer"* — none of which occurred. Recorded here rather than skipped silently.
Sign in to join this conversation.
No Label
P0
P1
P2
release-blocker
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: null/Privacy-Period-Tracker#45
No description provided.