LibreMediaConverterApp.onCreate launched its staging sweep on Dispatchers.IO. Robolectric builds
an Application per test class that asks for one, so on the JVM that is not one background sweep but
one per class — all over the shared <cacheDir>/conversions/, none of them joined by anything. Any
test asserting about a staged file was racing however many sweeps the classes before it had left in
flight.
It was CI-only (one occurrence on #149, run 33069641674) until wave 4 added ten Robolectric classes,
at which point OutputPublisherStagingTest started failing locally too.
The fix
LibreMediaConverterApp gains a protected open val sweepScope and publishes the Job that onCreate started. The JVM suite substitutes TestLibreMediaConverterApp through robolectric.properties, whose scope is Dispatchers.Unconfined — sweepStaging() is a plain
function that never suspends, so an Unconfinedlaunch runs it to completion before returning.
The SupervisorJob is kept so a throwing sweep is swallowed exactly as in production; the dispatcher
is the only intended difference.
Suite-wide rather than per-test opt-in, because 27 of the 58 Robolectric classes touch that
directory. Opting each one in was measured and rejected.
What it costs, knowingly
AppStartSweepTest used to open by asserting that the manifest's android:name is what Robolectric
instantiated — that the sweep is code which actually runs. That assertion cannot exist here any more:
an application= override replaces the manifest rather than being checked against it, and applicationInfo.className reports the override too (measured). So it is not merely unasserted, it is
unobservable from this source set, and a rewritten version would assert the override against itself.
The manifest link is device-only now. What survives is the as LibreMediaConverterApp cast in setUp, which catches only the test app ceasing to extend the real one — a smaller claim, and the PR
says so rather than implying the coverage is unchanged.
Verification — by mutation, not by repetition
AppStartSweepTest gains the sweep is finished before onCreate returns, which pins the property the
substitution exists for and is the only place it is checked.
mutation
result
drop the startupSweep = publication
red
test app back on Dispatchers.IO
red 5 / 5
The statistical arm is reported as a failure to reproduce, not as evidence. Running the whole
suite six times with the fix and six times mutated caught nothing in either arm. At the rate #159 was
observed at, a clean six-run arm is roughly a coin flip — the comparison was underpowered, so it
neither confirms nor refutes, and the deterministic mutation above is what the claim rests on.
AppStartSweepTest also joins the published Job instead of polling for ten seconds; a poll cannot
tell "swept" from "not started yet", and answered the second case by failing.
Also
OutputPublisherStagingTest's fixture KDoc described this race as live and closed with "is #159,
and is deliberately not fixed here". Corrected. The retry loop stays — it is what would catch the
substitution being undone.
CLAUDE.md gains the trap, including the withdrawn assertion.
Closes #159.
## The race
`LibreMediaConverterApp.onCreate` launched its staging sweep on `Dispatchers.IO`. Robolectric builds
an `Application` per test class that asks for one, so on the JVM that is not one background sweep but
one *per class* — all over the shared `<cacheDir>/conversions/`, none of them joined by anything. Any
test asserting about a staged file was racing however many sweeps the classes before it had left in
flight.
It was CI-only (one occurrence on #149, run `33069641674`) until wave 4 added ten Robolectric classes,
at which point `OutputPublisherStagingTest` started failing locally too.
## The fix
`LibreMediaConverterApp` gains a `protected open val sweepScope` and publishes the `Job` that
`onCreate` started. The JVM suite substitutes `TestLibreMediaConverterApp` through
`robolectric.properties`, whose scope is `Dispatchers.Unconfined` — `sweepStaging()` is a plain
function that never suspends, so an `Unconfined` `launch` runs it to completion before returning.
The `SupervisorJob` is kept so a throwing sweep is swallowed exactly as in production; the dispatcher
is the only intended difference.
**Suite-wide rather than per-test opt-in**, because 27 of the 58 Robolectric classes touch that
directory. Opting each one in was measured and rejected.
## What it costs, knowingly
`AppStartSweepTest` used to open by asserting that the manifest's `android:name` is what Robolectric
instantiated — that the sweep is code which actually runs. That assertion cannot exist here any more:
an `application=` override *replaces* the manifest rather than being checked against it, and
`applicationInfo.className` reports the override too (measured). So it is not merely unasserted, it is
unobservable from this source set, and a rewritten version would assert the override against itself.
**The manifest link is device-only now.** What survives is the `as LibreMediaConverterApp` cast in
`setUp`, which catches only the test app ceasing to extend the real one — a smaller claim, and the PR
says so rather than implying the coverage is unchanged.
## Verification — by mutation, not by repetition
`AppStartSweepTest` gains `the sweep is finished before onCreate returns`, which pins the property the
substitution exists for and is the only place it is checked.
| mutation | result |
|---|---|
| drop the `startupSweep =` publication | red |
| test app back on `Dispatchers.IO` | red **5 / 5** |
**The statistical arm is reported as a failure to reproduce, not as evidence.** Running the whole
suite six times with the fix and six times mutated caught nothing in either arm. At the rate #159 was
observed at, a clean six-run arm is roughly a coin flip — the comparison was underpowered, so it
neither confirms nor refutes, and the deterministic mutation above is what the claim rests on.
`AppStartSweepTest` also joins the published `Job` instead of polling for ten seconds; a poll cannot
tell "swept" from "not started yet", and answered the second case by failing.
## Also
- `OutputPublisherStagingTest`'s fixture KDoc described this race as live and closed with "is #159,
and is deliberately not fixed here". Corrected. The retry loop stays — it is what would catch the
substitution being undone.
- CLAUDE.md gains the trap, including the withdrawn assertion.
Gate: `assembleDebug` + `testDebugUnitTest` + `compileDebugAndroidTestKotlin` + `ktlintCheck` +
`detekt` + `lintDebug` all green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The flake this PR fixes just reproduced on CI — on a PR that sits below it
The PR body says the six-runs-per-arm comparison was underpowered and is "not offered as evidence".
That gap is now filled, by accident rather than design.
PR #217, run 34001741668, Unit tests leg, during the wave-4 merge train:
OutputPublisherStagingTest > a file that stops being collectable between the listing and the delete survives FAILED
java.lang.AssertionError at OutputPublisherStagingTest.kt:184
628 tests completed, 1 failed
Line 184 is:
assertTrue("a file a live job started writing after the listing must not be unlinked",orphan.exists(),)
The orphan is created with an mtime a full grace period plus 60 s in the past, and the test's own racing publisher is specifically constructed not to unlink it — the snapshot override touches
the file before the delete is reached, so isCollectable re-reads and refuses. So the sweep under
test did not delete it. Something else did, and the only other thing sweeping that directory is the
startup sweep of some other Robolectric Application, on Dispatchers.IO, that nothing joined.
That is this ticket's mechanism, reproduced, in the test #159 was filed about.
#217 is below this PR in the merge order, so it carries the unfixed Dispatchers.IO scope. The
merge train has now put ~14 full CI runs through the suite; this is the one that had the race and it
is on the one side of the change where the race still exists.
It does not upgrade the arm-A/arm-B comparison — six runs a side is still a coin flip at this rate,
and a single CI occurrence is not a measured frequency either. What it does is confirm the mechanism
is live on the code as it stands, which is the claim the deterministic mutation test makes
structurally and this makes empirically.
## The flake this PR fixes just reproduced on CI — on a PR that sits below it
The PR body says the six-runs-per-arm comparison was underpowered and is "not offered as evidence".
That gap is now filled, by accident rather than design.
PR #217, run `34001741668`, **Unit tests** leg, during the wave-4 merge train:
```
OutputPublisherStagingTest > a file that stops being collectable between the listing and the delete survives FAILED
java.lang.AssertionError at OutputPublisherStagingTest.kt:184
628 tests completed, 1 failed
```
Line 184 is:
```kotlin
assertTrue(
"a file a live job started writing after the listing must not be unlinked",
orphan.exists(),
)
```
The orphan is created with an mtime a full grace period plus 60 s in the past, and the test's own
`racing` publisher is specifically constructed **not** to unlink it — the `snapshot` override touches
the file before the delete is reached, so `isCollectable` re-reads and refuses. So the sweep under
test did not delete it. Something else did, and the only other thing sweeping that directory is the
startup sweep of some other Robolectric `Application`, on `Dispatchers.IO`, that nothing joined.
That is this ticket's mechanism, reproduced, in the test #159 was filed about.
**#217 is below this PR in the merge order**, so it carries the unfixed `Dispatchers.IO` scope. The
merge train has now put ~14 full CI runs through the suite; this is the one that had the race and it
is on the one side of the change where the race still exists.
It does not upgrade the arm-A/arm-B comparison — six runs a side is still a coin flip at this rate,
and a single CI occurrence is not a measured frequency either. What it does is confirm the mechanism
is live on the code as it stands, which is the claim the deterministic mutation test makes
structurally and this makes empirically.
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 #159.
The race
LibreMediaConverterApp.onCreatelaunched its staging sweep onDispatchers.IO. Robolectric buildsan
Applicationper test class that asks for one, so on the JVM that is not one background sweep butone per class — all over the shared
<cacheDir>/conversions/, none of them joined by anything. Anytest asserting about a staged file was racing however many sweeps the classes before it had left in
flight.
It was CI-only (one occurrence on #149, run
33069641674) until wave 4 added ten Robolectric classes,at which point
OutputPublisherStagingTeststarted failing locally too.The fix
LibreMediaConverterAppgains aprotected open val sweepScopeand publishes theJobthatonCreatestarted. The JVM suite substitutesTestLibreMediaConverterAppthroughrobolectric.properties, whose scope isDispatchers.Unconfined—sweepStaging()is a plainfunction that never suspends, so an
Unconfinedlaunchruns it to completion before returning.The
SupervisorJobis kept so a throwing sweep is swallowed exactly as in production; the dispatcheris the only intended difference.
Suite-wide rather than per-test opt-in, because 27 of the 58 Robolectric classes touch that
directory. Opting each one in was measured and rejected.
What it costs, knowingly
AppStartSweepTestused to open by asserting that the manifest'sandroid:nameis what Robolectricinstantiated — that the sweep is code which actually runs. That assertion cannot exist here any more:
an
application=override replaces the manifest rather than being checked against it, andapplicationInfo.classNamereports the override too (measured). So it is not merely unasserted, it isunobservable from this source set, and a rewritten version would assert the override against itself.
The manifest link is device-only now. What survives is the
as LibreMediaConverterAppcast insetUp, which catches only the test app ceasing to extend the real one — a smaller claim, and the PRsays so rather than implying the coverage is unchanged.
Verification — by mutation, not by repetition
AppStartSweepTestgainsthe sweep is finished before onCreate returns, which pins the property thesubstitution exists for and is the only place it is checked.
startupSweep =publicationDispatchers.IOThe statistical arm is reported as a failure to reproduce, not as evidence. Running the whole
suite six times with the fix and six times mutated caught nothing in either arm. At the rate #159 was
observed at, a clean six-run arm is roughly a coin flip — the comparison was underpowered, so it
neither confirms nor refutes, and the deterministic mutation above is what the claim rests on.
AppStartSweepTestalso joins the publishedJobinstead of polling for ten seconds; a poll cannottell "swept" from "not started yet", and answered the second case by failing.
Also
OutputPublisherStagingTest's fixture KDoc described this race as live and closed with "is #159,and is deliberately not fixed here". Corrected. The retry loop stays — it is what would catch the
substitution being undone.
Gate:
assembleDebug+testDebugUnitTest+compileDebugAndroidTestKotlin+ktlintCheck+detekt+lintDebugall green.🤖 Generated with Claude Code
The flake this PR fixes just reproduced on CI — on a PR that sits below it
The PR body says the six-runs-per-arm comparison was underpowered and is "not offered as evidence".
That gap is now filled, by accident rather than design.
PR #217, run
34001741668, Unit tests leg, during the wave-4 merge train:Line 184 is:
The orphan is created with an mtime a full grace period plus 60 s in the past, and the test's own
racingpublisher is specifically constructed not to unlink it — thesnapshotoverride touchesthe file before the delete is reached, so
isCollectablere-reads and refuses. So the sweep undertest did not delete it. Something else did, and the only other thing sweeping that directory is the
startup sweep of some other Robolectric
Application, onDispatchers.IO, that nothing joined.That is this ticket's mechanism, reproduced, in the test #159 was filed about.
#217 is below this PR in the merge order, so it carries the unfixed
Dispatchers.IOscope. Themerge train has now put ~14 full CI runs through the suite; this is the one that had the race and it
is on the one side of the change where the race still exists.
It does not upgrade the arm-A/arm-B comparison — six runs a side is still a coin flip at this rate,
and a single CI occurrence is not a measured frequency either. What it does is confirm the mechanism
is live on the code as it stands, which is the claim the deterministic mutation test makes
structurally and this makes empirically.