Finish the startup sweep before onCreate returns, in the JVM suite #218

Merged
JMR-dev merged 2 commits from fix/injectable-startup-sweep-scope into main 2026-09-06 01:43:25 +00:00
JMR-dev commented 2026-09-06 00:15:34 +00:00 (Migrated from github.com)

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

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)
JMR-dev commented 2026-09-06 01:21:54 +00:00 (Migrated from github.com)

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.
Sign in to join this conversation.