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.
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)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #66.
ConversionViewModel.onInputPickedhopped to a hard-codedDispatchers.IOinside aviewModelScope.launchwith no exception handler. The missing handler is deliberate — the KDocsays a real
OutOfMemoryErrorshould reach the thread's default handler and take the processdown — but on the JVM there is no such handler. kotlinx-coroutines-test's
ExceptionCollectorisinstalled process-wide via
ServiceLoader,enablednever goes back tofalse, and when norunTestcallback is registered it stores the throwable and hands it to whicheverrunTeststarts next. Every Compose rule is a
runTest, so the OOM raised byConversionViewModelProbeFailureTestfailed a different, innocent Compose class, and which onemoved between runs.
What changed
1. The seam. A
pickDispatcherconstructor parameter, defaulting toDispatchers.IO, exactlyas
cleanupDispatcheralready did. With the hop on a dispatcher a test controls, the throw happensinside 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
probeDispatcherfor 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 swallowednow asserts the realOutOfMemoryErrorinstance, which was impossible before — the old test could only infer it from acard 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 insideonInputPicked.launchhands an escaped error to the handler chain and never to its caller, sonothing 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.IOturns itred:
expected java.lang.OutOfMemoryError to be thrown, but nothing was thrown.3.
EscapedCoroutineErrors.ktis deleted.runCatching { runTest {} }discarded whatever itfound, 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
StateRestorationTesterinteroperate now sits at the two declarations that pair them, and — for therecord, since nothing else records it — a drain could never have been a
@Before(the rule'srunTestwraps 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
testImplementationline and its catalog entrynamed
EscapedCoroutineErrors.ktas the only reasonkotlinx-coroutines-testis on the testclasspath. The dependency is still needed —
runTestis 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.ktand
ConversionViewModelProbeFailureTest.ktreverted toorigin/main,EscapedCoroutineErrors.ktstill deleted, consumers still on the direct v2 import. Full JVM suite,
--rerun, twice.Run 1 — 373 tests, 1 failure:
Run 2 — 373 tests, 1 failure:
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 caseIt shares the shape —
onInputsPickedis aviewModelScope.launchwith no handler and awithContext(Dispatchers.IO)inside it — but it cannot be a depositor, and the difference is notcosmetic:
ConversionViewModel's did:probeOrUnreadablerethrowson purpose for anything that is not a native load failure.
JoinViewModeldoes not probe at all.InputQuery.describe, isrunCatching-guarded on every path —firstRowandmeasure— andrunCatchingcatchesThrowable, not justException.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()andobserve()have it in bothViewModels. What made the probe the only depositor is not the launch, it is the deliberate rethrow
inside it.
So nothing in
JoinViewModelcan escape its launch today. No seam was added, because an untestedconstructor parameter with no caller is not an improvement, and no issue was filed, because there
is no defect to file. If
JoinViewModelever grows a call that throws on purpose, it needs thissame seam.
Gate
assembleDebug+testDebugUnitTest+compileDebugAndroidTestKotlin+ktlintCheck+detekt+lintDebug --continue: green.LongParameterListis unaffected — the constructor goes from 2parameters to 3, against a threshold of 6.
Not covered
pickDispatcheron a real device. The default isDispatchers.IO, i.e. exactly what the twowithContextcalls named before, so the shipped behaviour is unchanged by construction. Only theJVM suite substitutes it.
Dispatchers.IOhops insave()andreset(). Untouched.savewraps its hop inrunCatching, andreset's dispatcher was already injectable.🤖 Generated with Claude Code