Application scope has no CoroutineExceptionHandler — a Room flow throw kills headless processes #45
Labels
No Label
P0
P1
P2
release-blocker
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: null/Privacy-Period-Tracker#45
Loading…
Reference in New Issue
No description provided.
Delete Branch "%!s(<nil>)"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.ktbuilds the scope that outlives every screen:ReminderCoordinator.start()thenlaunchIns two Room flows on it.SupervisorJobis the trap. It stops a failing child cancelling its siblings — it does not stop the exception. An uncaught throw insidelaunchgoes to theCoroutineExceptionHandlerin 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:65installs a handler and its KDoc names the lesson: "Batch 01's lesson, learned by tapping a button twice: a repository call that throws insideviewModelScope.launchkills the app."TodayViewModelhas one too. The one scope that runs with no user present is the one without.The concrete trigger
This is not hypothetical.
repository.forecastruns the prediction engine inside the flow — it combines confirmed periods, "not yet" observations and scored errors, then callsengine.predict(...).Prediction's init block enforces its invariants withrequire, so an engine result where the window is inverted or the most-likely date falls outside it throwsIllegalArgumentExceptiondirectly 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:RescheduleReceiverafter a reboot (RECEIVE_BOOT_COMPLETED, which the permission allowlist grants for exactly this)SystemJobServicewhen the daily reminder firesForceStopRunnable$BroadcastReceiverSo 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 notry/catch. A throw becomesResult.failure(), which for periodic work means that run is simply lost — silent, and indistinguishable from having nothing to say.What to do
CoroutineExceptionHandleron the application scope inPeriodApplication.kt..catch { }on both flow chains inReminderCoordinator.start()so a repository failure degrades to "no reminder" rather than killing the process.ReminderWorker.doWork()returnsResult.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.
checkNoHealthLoggingforbids logging calls inapp/**, and an exception message here can carry a health-derived value — which is exactly the leakNoDatesInDiagnosticsTestexists 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.
Fixed in
424a513.What changed
app/.../PeriodApplication.ktapplicationScope()and carries aCoroutineExceptionHandlerapp/.../notifications/ReminderCoordinator.kt.catch { }es its own failure; chains extracted tointernalfunctions taking flowscore/notifications/.../ReminderWorker.ktdoWorkwrapsdecideAndNotify(), returns success and posts nothing on failure, rethrowsCancellationExceptionEvidence
ApplicationScopeTestandReminderCoordinatorTest, 6 tests, all executed:Whole repo after the change: 207 tests, 0 failures, 0 errors, 0 skipped.
checkModuleBoundaries,checkNoHealthLoggingandcheckPermissionsall exit 0,the last against a freshly built release manifest.
Proved to fail, per GUARDS.md §1
Removing the handler fails exactly one test:
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
catchthat swallowed everything could not pass the other three byaccident.
Three things found while doing it
1.
prove-guard.shreported a catch that never happened. The first proof ran./gradlew :app:test --tests '*ApplicationScopeTest*'.:app:testis AGP'slifecycle task and takes no
--testsoption, so Gradle failed withUnknown command-line option '--tests'in 544 ms — the mutation was nevercompiled 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.mdpresents 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:andBUILD FAILED. One caught violation reads as three. All three now carryPROVE_GUARD_FAIL_PATTERNand were re-run: exit 0, 0, 0.Both lessons are recorded in
docs/architecture/GUARDS.md§8, whose reviewtrigger — "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:assembleDebugfailed 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:cleancleared it. Not caused by thischange; it was surfaced by the new class the fix introduced.
Not updated, deliberately
doc-triggers.pyfiresdocs/design/README.mdon path. Its trigger is "Any newuser-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.