Give the probe hop an injectable dispatcher, and delete the drain it replaces #94

Merged
JMR-dev merged 4 commits from fix/probe-dispatcher-seam into main 2026-08-25 05:40:58 +00:00
JMR-dev commented 2026-08-25 04:13:37 +00:00 (Migrated from github.com)

Closes #66.

ConversionViewModel.onInputPicked hopped to a hard-coded Dispatchers.IO inside a
viewModelScope.launch with no exception handler. The missing handler is deliberate — the KDoc
says a real OutOfMemoryError should reach the thread's default handler and take the process
down — but on the JVM there is no such handler. kotlinx-coroutines-test's ExceptionCollector is
installed process-wide via ServiceLoader, enabled never goes back to false, and when no
runTest callback is registered it stores the throwable and hands it to whichever runTest
starts next. Every Compose rule is a runTest, so the OOM raised by
ConversionViewModelProbeFailureTest failed a different, innocent Compose class, and which one
moved between runs.

What changed

1. The seam. A pickDispatcher constructor parameter, defaulting to Dispatchers.IO, exactly
as cleanupDispatcher already did. With the hop on a dispatcher a test controls, the throw happens
inside that test's own window, where the collector's callback belongs to the scope that caused it —
so it is consumed rather than collected.

Deviation from the issue, deliberate: #66 proposed a probeDispatcher for the probe hop alone.
This covers both hops of a pick. Leaving the metadata query on a real IO thread makes the
coroutine resume on a main looper that Robolectric leaves paused, and that bounce is precisely the
asynchrony that made delivery unpredictable. One dispatcher covers a whole pick and leaves nothing
about it to timing.

2. The second bite. an OutOfMemoryError is not swallowed now asserts the real
OutOfMemoryError instance, which was impossible before — the old test could only infer it from a
card that never filled in, an assertion that also passes when the probe simply returns null.

Precision about "at the call": it surfaces at the end of runTest, not inside
onInputPicked. launch hands an escaped error to the handler chain and never to its caller, so
nothing can catch it at the call itself. This is as close as the coroutine machinery allows, and it
is a real throwable rather than a state inference. Reverting the hop to Dispatchers.IO turns it
red: expected java.lang.OutOfMemoryError to be thrown, but nothing was thrown.

3. EscapedCoroutineErrors.kt is deleted. runCatching { runTest {} } discarded whatever it
found, so a future coroutine failure nobody asserted on would have become silent instead of failing
a random later test. All 11 Compose classes now take the v2 createComposeRule() directly.

Two findings from its KDoc that outlive it are kept: the note that the v2 rule and the non-v2
StateRestorationTester interoperate now sits at the two declarations that pair them, and — for the
record, since nothing else records it — a drain could never have been a @Before (the rule's
runTest wraps the statement that calls it, so it has already thrown) or a @BeforeClass
(Robolectric runs that outside the sandbox classloader, where the collector is a different object).

4. Two build comments repointed. Both the testImplementation line and its catalog entry
named EscapedCoroutineErrors.kt as the only reason kotlinx-coroutines-test is on the test
classpath. The dependency is still needed — runTest is what registers the collector callback —
but the file is gone, and nothing in the gate fails on prose naming a deleted file.

The mutation: the flake comes back

This is a heisenbug, so the bar is that reverting the seam makes it reappear. Tree: ConversionViewModel.kt
and ConversionViewModelProbeFailureTest.kt reverted to origin/main, EscapedCoroutineErrors.kt
still deleted, consumers still on the direct v2 import. Full JVM suite, --rerun, twice.

Run 1 — 373 tests, 1 failure:

CLASS : org.libremediaconverter.convert.ConverterLeafTagsTest
TEST  : the file card tags the note it shows while the probe is still running
MSG   : kotlinx.coroutines.test.UncaughtExceptionsBeforeTest: There were uncaught exceptions
        before the test started. ...
	at kotlinx.coroutines.test.TestScopeImpl.enter(TestScope.kt:238)
	at androidx.compose.ui.test.AndroidComposeUiTestEnvironment.runTest(ComposeUiTest.android.kt:665)
	...
	Suppressed: java.lang.OutOfMemoryError: Failed to allocate 512 MB
		at org.libremediaconverter.convert.ConversionViewModelProbeFailureTest.an_OutOfMemoryError_is_not_swallowed$lambda$0(...)

Run 2 — 373 tests, 1 failure:

CLASS : org.libremediaconverter.convert.ConverterPickerSelectionTest
TEST  : clicking a quality chip reports that tier
MSG   : kotlinx.coroutines.test.UncaughtExceptionsBeforeTest: There were uncaught exceptions
        before the test started. ...

Identical code, two runs, two different victim classes, neither of which caused it, each
carrying the other class's OOM as a suppressed throwable. That is the reported symptom exactly.

With the seam in place and the drain deleted, the same suite runs 373 tests, 0 failures, twice in
a row
.

JoinViewModel: checked, and it is not the same case

It shares the shape — onInputsPicked is a viewModelScope.launch with no handler and a
withContext(Dispatchers.IO) inside it — but it cannot be a depositor, and the difference is not
cosmetic:

  • The hop contains no unguarded call. ConversionViewModel's did: probeOrUnreadable rethrows
    on purpose for anything that is not a native load failure. JoinViewModel does not probe at all.
  • Its one call, InputQuery.describe, is runCatching-guarded on every path — firstRow and
    measure — and runCatching catches Throwable, not just Exception.

Worth saying plainly, because it is what makes the verdict hold rather than weaken it: the
launch-without-a-handler shape is repo-wide — reattach() and observe() have it in both
ViewModels. What made the probe the only depositor is not the launch, it is the deliberate rethrow
inside it.

So nothing in JoinViewModel can escape its launch today. No seam was added, because an untested
constructor parameter with no caller is not an improvement, and no issue was filed, because there
is no defect to file. If JoinViewModel ever grows a call that throws on purpose, it needs this
same seam.

Gate

assembleDebug + testDebugUnitTest + compileDebugAndroidTestKotlin + ktlintCheck + detekt +
lintDebug --continue: green. LongParameterList is unaffected — the constructor goes from 2
parameters to 3, against a threshold of 6.

Not covered

  • pickDispatcher on a real device. The default is Dispatchers.IO, i.e. exactly what the two
    withContext calls named before, so the shipped behaviour is unchanged by construction. Only the
    JVM suite substitutes it.
  • The Dispatchers.IO hops in save() and reset(). Untouched. save wraps its hop in
    runCatching, and reset's dispatcher was already injectable.

🤖 Generated with Claude Code

Closes #66. `ConversionViewModel.onInputPicked` hopped to a hard-coded `Dispatchers.IO` inside a `viewModelScope.launch` with no exception handler. The missing handler is deliberate — the KDoc says a real `OutOfMemoryError` should reach the thread's default handler and take the process down — but on the JVM there is no such handler. kotlinx-coroutines-test's `ExceptionCollector` is installed process-wide via `ServiceLoader`, `enabled` never goes back to `false`, and when no `runTest` callback is registered it **stores** the throwable and hands it to whichever `runTest` starts next. Every Compose rule is a `runTest`, so the OOM raised by `ConversionViewModelProbeFailureTest` failed a *different*, innocent Compose class, and which one moved between runs. ## What changed **1. The seam.** A `pickDispatcher` constructor parameter, defaulting to `Dispatchers.IO`, exactly as `cleanupDispatcher` already did. With the hop on a dispatcher a test controls, the throw happens inside that test's own window, where the collector's callback belongs to the scope that caused it — so it is consumed rather than collected. *Deviation from the issue, deliberate:* #66 proposed a `probeDispatcher` for the probe hop alone. This covers **both** hops of a pick. Leaving the metadata query on a real IO thread makes the coroutine resume on a main looper that Robolectric leaves paused, and that bounce is precisely the asynchrony that made delivery unpredictable. One dispatcher covers a whole pick and leaves nothing about it to timing. **2. The second bite.** `an OutOfMemoryError is not swallowed` now asserts the real `OutOfMemoryError` instance, which was impossible before — the old test could only infer it from a card that never filled in, an assertion that also passes when the probe simply returns null. **Precision about "at the call":** it surfaces at the end of `runTest`, **not** inside `onInputPicked`. `launch` hands an escaped error to the handler chain and never to its caller, so nothing can catch it at the call itself. This is as close as the coroutine machinery allows, and it is a real throwable rather than a state inference. Reverting the hop to `Dispatchers.IO` turns it red: `expected java.lang.OutOfMemoryError to be thrown, but nothing was thrown`. **3. `EscapedCoroutineErrors.kt` is deleted.** `runCatching { runTest {} }` discarded whatever it found, so a future coroutine failure nobody asserted on would have become silent instead of failing a random later test. All 11 Compose classes now take the v2 `createComposeRule()` directly. Two findings from its KDoc that outlive it are kept: the note that the v2 rule and the non-v2 `StateRestorationTester` interoperate now sits at the two declarations that pair them, and — for the record, since nothing else records it — **a drain could never have been a `@Before`** (the rule's `runTest` wraps the statement that calls it, so it has already thrown) **or a `@BeforeClass`** (Robolectric runs that outside the sandbox classloader, where the collector is a different object). **4. Two build comments repointed.** Both the `testImplementation` line and its catalog entry named `EscapedCoroutineErrors.kt` as the only reason `kotlinx-coroutines-test` is on the test classpath. The dependency is still needed — `runTest` is what registers the collector callback — but the file is gone, and nothing in the gate fails on prose naming a deleted file. ## The mutation: the flake comes back This is a heisenbug, so the bar is that reverting the seam makes it reappear. Tree: `ConversionViewModel.kt` and `ConversionViewModelProbeFailureTest.kt` reverted to `origin/main`, `EscapedCoroutineErrors.kt` still deleted, consumers still on the direct v2 import. Full JVM suite, `--rerun`, twice. **Run 1 — 373 tests, 1 failure:** ``` CLASS : org.libremediaconverter.convert.ConverterLeafTagsTest TEST : the file card tags the note it shows while the probe is still running MSG : kotlinx.coroutines.test.UncaughtExceptionsBeforeTest: There were uncaught exceptions before the test started. ... at kotlinx.coroutines.test.TestScopeImpl.enter(TestScope.kt:238) at androidx.compose.ui.test.AndroidComposeUiTestEnvironment.runTest(ComposeUiTest.android.kt:665) ... Suppressed: java.lang.OutOfMemoryError: Failed to allocate 512 MB at org.libremediaconverter.convert.ConversionViewModelProbeFailureTest.an_OutOfMemoryError_is_not_swallowed$lambda$0(...) ``` **Run 2 — 373 tests, 1 failure:** ``` CLASS : org.libremediaconverter.convert.ConverterPickerSelectionTest TEST : clicking a quality chip reports that tier MSG : kotlinx.coroutines.test.UncaughtExceptionsBeforeTest: There were uncaught exceptions before the test started. ... ``` Identical code, two runs, **two different victim classes**, neither of which caused it, each carrying the other class's OOM as a suppressed throwable. That is the reported symptom exactly. With the seam in place and the drain deleted, the same suite runs **373 tests, 0 failures, twice in a row**. ## `JoinViewModel`: checked, and it is not the same case It shares the *shape* — `onInputsPicked` is a `viewModelScope.launch` with no handler and a `withContext(Dispatchers.IO)` inside it — but it cannot be a depositor, and the difference is not cosmetic: - The hop contains **no unguarded call**. `ConversionViewModel`'s did: `probeOrUnreadable` rethrows on purpose for anything that is not a native load failure. `JoinViewModel` does not probe at all. - Its one call, `InputQuery.describe`, is `runCatching`-guarded on **every** path — `firstRow` and `measure` — and `runCatching` catches `Throwable`, not just `Exception`. Worth saying plainly, because it is what makes the verdict hold rather than weaken it: the launch-without-a-handler shape is **repo-wide** — `reattach()` and `observe()` have it in both ViewModels. What made the probe the only depositor is not the launch, it is the deliberate rethrow inside it. So nothing in `JoinViewModel` can escape its launch today. No seam was added, because an untested constructor parameter with no caller is not an improvement, and no issue was filed, because there is no defect to file. If `JoinViewModel` ever grows a call that throws on purpose, it needs this same seam. ## Gate `assembleDebug` + `testDebugUnitTest` + `compileDebugAndroidTestKotlin` + `ktlintCheck` + `detekt` + `lintDebug --continue`: green. `LongParameterList` is unaffected — the constructor goes from 2 parameters to 3, against a threshold of 6. ## Not covered - **`pickDispatcher` on a real device.** The default is `Dispatchers.IO`, i.e. exactly what the two `withContext` calls named before, so the shipped behaviour is unchanged by construction. Only the JVM suite substitutes it. - **The `Dispatchers.IO` hops in `save()` and `reset()`.** Untouched. `save` wraps its hop in `runCatching`, and `reset`'s dispatcher was already injectable. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.