ReattachOnLaunchTest.doesNotOverwriteAPickTheUserHasAlreadyMade has failed four gating legs since
2026-08-24, on API 33, 35 and 36, every one on attempt 1 and every one green on re-run. Read as
flake for two days. It is not: the assertion is expected null, but was:<Converted>, and the state
that took the screen is a finished job from an earlier session. Open the app, pick a file, and watch
a stale conversion replace your pick.
The interleaving
reattach() read _state.value, found it Idle, and handed the job to observe() — which launches a separate coroutine that cannot write until its collect has resumed with a WorkInfo.
The check happens at one moment, the write lands at another, and a whole pick fits in between:
init starts the tag query and suspends in it.
The user picks; onInputPicked suspends in its metadata query.
The query returns. The screen is still Idle — step 2 has not written yet — so the guard passes
and an observation of the old job is launched.
The pick lands. Ready(picked). The user owns the screen.
The observation's first WorkInfo arrives and writes Converted(yesterday) over it.
The comment above that guard said "Both this check and the assignment below run on the main
dispatcher with no suspension point between them, so nothing can interleave." There is no
assignment below, and the two lines are in different coroutines. That sentence is why this sat as
CI noise. It is deleted here, replaced with what actually happens.
The mechanism: ScreenOwnership
A claim is taken synchronously, when the user acts. Every write that lands after a suspension
point checks the claim it was made under and drops itself if that claim has been superseded.
Dropped, not reordered — a dropped write cannot come back later. The check sits immediately before
the write with no suspension point between them, and both run on the main dispatcher, which is the
atomicity argument the old comment only asserted.
Cancelling the old observer was never sufficient, and the fix does not rest on it (both
ViewModels still cancel, because a live collector is a leak):
Job.cancel is a request, honoured at the next suspension point. A collector that has already
resumed and is on its way to _state.value = … has none left, so the write lands anyway.
More basically, in the case reported there is nothing to cancel. Nothing supersedes the
observation until after it has been launched.
The check sits ahead of the when, not merely ahead of the assignment: the SUCCEEDED branch takes
ownership of the staged file, and a superseded observation must not do that either.
JoinViewModel had the identical shape and nothing watching it, so it gets the same fix. Its
pick dispatcher becomes injectable for the same reason ConversionViewModel's already was (#94):
without that seam there is no way to ask what happens while a pick is still in flight.
Two changes, not one
The observation token is #49's fix. The pick-side guards are a second defect of the same class,
found while fixing it and closed alongside: onInputPicked's two writes both land after a hop, so
two taps in quick succession put the loser's file on screen if its query came back second. Measured,
not assumed — see the mutations.
The tests are the evidence, not CI
The device test catches this about once in 45 leg-attempts. One green CI run proves almost nothing
about this race. What proves it is that the JVM reproduces it deterministically, and did so before
any ownership code existed.
ReattachmentOwnershipTest / JoinReattachmentOwnershipTest park the pick on a dispatcher the test
owns, so "issued but not yet written" is a state the test sits in rather than one it races for. reattach's continuation comes back from a real IO thread and can only be a message posted to a
main looper Robolectric leaves paused, so the pick is always issued before the guard runs.
Neither test rests on a settle window: a second ViewModel with nothing to supersede it reattaches
through the same code, and when it has arrived the whole query→guard→observe→write path has
demonstrably run. The one under test started first, so it has had at least as long.
The Join test was written first and confirmed red with only the dispatcher seam added and no
ownership code:
reattachment took the screen from the user: Joined(staged=…/joined-yesterday.mp4,
strategy=STREAM_COPY, suggestedName=joined.mp4, mimeType=video/mp4)
The convert one reproduces the CI shape exactly:
reattachment took the screen from the user: Converted(input=InputFile(uri=, displayName=holiday.mp4,
sizeBytes=4096, probe=null), staged=…/conversions/holiday_converted.mp4, …)
Mutations
16 applied, each against the whole JVM suite, each restored after.
Bite (12):
#
mutation
reddens
M1
delete if (!ownership.stillHeldBy(token)) return@collect in ConversionViewModel.observe
ReattachmentOwnershipTest > a conversion found while the user was picking never reaches the screen
M2
same in JoinViewModel.observe
JoinReattachmentOwnershipTest > a join found while the user was picking never reaches the screen
M15
token = token → token = ownership.current at observe(...) in ConversionViewModel.reattach
same as M1
M16
same in JoinViewModel.reattach
same as M2
M5
delete the guard before _state.value = ConversionState.Ready(file)
PickOwnershipTest > the slower of two picks does not land on top of the faster one
M6
delete the guard before the probe write
10 tests, across PickOwnershipTest, FailedSaveRetryTest, ConversionViewModelNamingTest, ConversionViewModelCleanupTest, MissingStagedFileTest
M7
delete the guard before _state.value = JoinState.Ready(files)
JoinPickOwnershipTest > the slower of two selections does not land on top of the faster one
M8
if (true) return@collect in ConversionViewModel.observe — the reverse check
14 tests, including both positive controls
M9
same in JoinViewModel.observe
9 tests, including the join positive control
M12
ownership.claim() → ownership.current in convert()
12 tests
M8/M9 are the "does legitimate reattachment still land" check: dropping every observation reddens a conversion nobody has superseded still reaches the screen, its join twin, and the existing
cleanup/naming/save suites.
Could not fail — two different reasons, and neither is "it bites, trust me":
Not measurable by this harness. Robolectric runs Dispatchers.Main.immediate inline, so a claim
moved inside a launch still happens synchronously and the mutation is a no-op here:
M3 / M4 — val token = ownership.current moved inside reattach's launch. Superseded by
M15/M16, which are the semantic version and do bite.
M10 — ownership.claim() moved inside onInputPicked's launch. The placement is defensive
against a dispatcher that does not run inline; this harness cannot produce one.
Genuinely untested lines. No test constructs the situation:
M11 / M14 — deleting ownership.claim() from reset() in either ViewModel.
M13 — ownership.claim() → ownership.current in join().
M11/M14 are unreddenable only because save() is exempt, and those are the same fact: the only
deferred write that could land on top of a reset() is save's. That gap is real and now filed as #123 — a save that finishes after "Start over" puts Saved back on a screen the user cleared.
It is not fixed here because closing it means choosing between "report nothing for a file that may
genuinely have been published" and "report success for work the user dismissed", which is a question
about what the screen offers during a save, not about this race. M13 cannot bite because join() is
only reachable from Ready, so the join pick's single deferred write has always landed by then; the
claim is there for consistency with convert().
Not changed
The instrumented tests are untouched. ReattachGuardsTest still covers the case the plain guard does catch — a pick that has already landed — and that guard stays, with a corrected comment.
Gate
assembleDebug + testDebugUnitTest + compileDebugAndroidTestKotlin + ktlintCheck + detekt + lintDebug, all green locally.
Closes #49.
## This was a product race, and the test was right
`ReattachOnLaunchTest.doesNotOverwriteAPickTheUserHasAlreadyMade` has failed four gating legs since
2026-08-24, on API 33, 35 and 36, every one on attempt 1 and every one green on re-run. Read as
flake for two days. It is not: the assertion is `expected null, but was:<Converted>`, and the state
that took the screen is a finished job from an earlier session. Open the app, pick a file, and watch
a stale conversion replace your pick.
### The interleaving
`reattach()` read `_state.value`, found it `Idle`, and handed the job to `observe()` — which
*launches a separate coroutine* that cannot write until its `collect` has resumed with a `WorkInfo`.
The check happens at one moment, the write lands at another, and a whole pick fits in between:
1. `init` starts the tag query and suspends in it.
2. The user picks; `onInputPicked` suspends in its metadata query.
3. The query returns. The screen is still `Idle` — step 2 has not written yet — so the guard passes
and an observation of the old job is launched.
4. The pick lands. `Ready(picked)`. The user owns the screen.
5. The observation's first `WorkInfo` arrives and writes `Converted(yesterday)` over it.
The comment above that guard said *"Both this check and the assignment below run on the main
dispatcher with no suspension point between them, so nothing can interleave."* There is no
assignment below, and the two lines are in different coroutines. That sentence is why this sat as
CI noise. It is deleted here, replaced with what actually happens.
## The mechanism: `ScreenOwnership`
A claim is taken **synchronously, when the user acts**. Every write that lands after a suspension
point checks the claim it was made under and **drops itself** if that claim has been superseded.
Dropped, not reordered — a dropped write cannot come back later. The check sits immediately before
the write with no suspension point between them, and both run on the main dispatcher, which is the
atomicity argument the old comment only asserted.
**Cancelling the old observer was never sufficient**, and the fix does not rest on it (both
ViewModels still cancel, because a live collector is a leak):
- `Job.cancel` is a *request*, honoured at the next suspension point. A collector that has already
resumed and is on its way to `_state.value = …` has none left, so the write lands anyway.
- More basically, in the case reported there is nothing to cancel. Nothing supersedes the
observation until *after* it has been launched.
The check sits ahead of the `when`, not merely ahead of the assignment: the SUCCEEDED branch takes
ownership of the staged file, and a superseded observation must not do that either.
`JoinViewModel` had the identical shape and **nothing watching it**, so it gets the same fix. Its
pick dispatcher becomes injectable for the same reason `ConversionViewModel`'s already was (#94):
without that seam there is no way to ask what happens while a pick is still in flight.
## Two changes, not one
The observation token is #49's fix. The **pick-side guards are a second defect of the same class**,
found while fixing it and closed alongside: `onInputPicked`'s two writes both land after a hop, so
two taps in quick succession put the loser's file on screen if its query came back second. Measured,
not assumed — see the mutations.
## The tests are the evidence, not CI
The device test catches this about once in 45 leg-attempts. **One green CI run proves almost nothing
about this race.** What proves it is that the JVM reproduces it deterministically, and did so before
any ownership code existed.
`ReattachmentOwnershipTest` / `JoinReattachmentOwnershipTest` park the pick on a dispatcher the test
owns, so "issued but not yet written" is a state the test sits in rather than one it races for.
`reattach`'s continuation comes back from a real IO thread and can only be a message posted to a
main looper Robolectric leaves paused, so the pick is *always* issued before the guard runs.
Neither test rests on a settle window: a second ViewModel with nothing to supersede it reattaches
through the same code, and when *it* has arrived the whole query→guard→observe→write path has
demonstrably run. The one under test started first, so it has had at least as long.
The Join test was written **first** and confirmed red with only the dispatcher seam added and no
ownership code:
```
reattachment took the screen from the user: Joined(staged=…/joined-yesterday.mp4,
strategy=STREAM_COPY, suggestedName=joined.mp4, mimeType=video/mp4)
```
The convert one reproduces the CI shape exactly:
```
reattachment took the screen from the user: Converted(input=InputFile(uri=, displayName=holiday.mp4,
sizeBytes=4096, probe=null), staged=…/conversions/holiday_converted.mp4, …)
```
## Mutations
16 applied, each against the whole JVM suite, each restored after.
**Bite (12):**
| # | mutation | reddens |
|---|---|---|
| M1 | delete `if (!ownership.stillHeldBy(token)) return@collect` in `ConversionViewModel.observe` | `ReattachmentOwnershipTest > a conversion found while the user was picking never reaches the screen` |
| M2 | same in `JoinViewModel.observe` | `JoinReattachmentOwnershipTest > a join found while the user was picking never reaches the screen` |
| M15 | `token = token` → `token = ownership.current` at `observe(...)` in `ConversionViewModel.reattach` | same as M1 |
| M16 | same in `JoinViewModel.reattach` | same as M2 |
| M5 | delete the guard before `_state.value = ConversionState.Ready(file)` | `PickOwnershipTest > the slower of two picks does not land on top of the faster one` |
| M6 | delete the guard before the probe write | 10 tests, across `PickOwnershipTest`, `FailedSaveRetryTest`, `ConversionViewModelNamingTest`, `ConversionViewModelCleanupTest`, `MissingStagedFileTest` |
| M7 | delete the guard before `_state.value = JoinState.Ready(files)` | `JoinPickOwnershipTest > the slower of two selections does not land on top of the faster one` |
| M8 | `if (true) return@collect` in `ConversionViewModel.observe` — the reverse check | 14 tests, including both positive controls |
| M9 | same in `JoinViewModel.observe` | 9 tests, including the join positive control |
| M12 | `ownership.claim()` → `ownership.current` in `convert()` | 12 tests |
M8/M9 are the "does legitimate reattachment still land" check: dropping every observation reddens
`a conversion nobody has superseded still reaches the screen`, its join twin, and the existing
cleanup/naming/save suites.
**Could not fail — two different reasons, and neither is "it bites, trust me":**
*Not measurable by this harness.* Robolectric runs `Dispatchers.Main.immediate` inline, so a claim
moved inside a `launch` still happens synchronously and the mutation is a no-op here:
- **M3 / M4** — `val token = ownership.current` moved inside `reattach`'s `launch`. Superseded by
M15/M16, which are the semantic version and do bite.
- **M10** — `ownership.claim()` moved inside `onInputPicked`'s `launch`. The placement is defensive
against a dispatcher that does *not* run inline; this harness cannot produce one.
*Genuinely untested lines.* No test constructs the situation:
- **M11 / M14** — deleting `ownership.claim()` from `reset()` in either ViewModel.
- **M13** — `ownership.claim()` → `ownership.current` in `join()`.
M11/M14 are unreddenable **only because `save()` is exempt**, and those are the same fact: the only
deferred write that could land on top of a `reset()` is `save`'s. That gap is real and now filed as
**#123** — a save that finishes after "Start over" puts `Saved` back on a screen the user cleared.
It is not fixed here because closing it means choosing between "report nothing for a file that may
genuinely have been published" and "report success for work the user dismissed", which is a question
about what the screen offers during a save, not about this race. M13 cannot bite because `join()` is
only reachable from `Ready`, so the join pick's single deferred write has always landed by then; the
claim is there for consistency with `convert()`.
## Not changed
The instrumented tests are untouched. `ReattachGuardsTest` still covers the case the plain guard
*does* catch — a pick that has already landed — and that guard stays, with a corrected comment.
## Gate
`assembleDebug` + `testDebugUnitTest` + `compileDebugAndroidTestKotlin` + `ktlintCheck` + `detekt` +
`lintDebug`, all green locally.
🤖 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 #49.
This was a product race, and the test was right
ReattachOnLaunchTest.doesNotOverwriteAPickTheUserHasAlreadyMadehas failed four gating legs since2026-08-24, on API 33, 35 and 36, every one on attempt 1 and every one green on re-run. Read as
flake for two days. It is not: the assertion is
expected null, but was:<Converted>, and the statethat took the screen is a finished job from an earlier session. Open the app, pick a file, and watch
a stale conversion replace your pick.
The interleaving
reattach()read_state.value, found itIdle, and handed the job toobserve()— whichlaunches a separate coroutine that cannot write until its
collecthas resumed with aWorkInfo.The check happens at one moment, the write lands at another, and a whole pick fits in between:
initstarts the tag query and suspends in it.onInputPickedsuspends in its metadata query.Idle— step 2 has not written yet — so the guard passesand an observation of the old job is launched.
Ready(picked). The user owns the screen.WorkInfoarrives and writesConverted(yesterday)over it.The comment above that guard said "Both this check and the assignment below run on the main
dispatcher with no suspension point between them, so nothing can interleave." There is no
assignment below, and the two lines are in different coroutines. That sentence is why this sat as
CI noise. It is deleted here, replaced with what actually happens.
The mechanism:
ScreenOwnershipA claim is taken synchronously, when the user acts. Every write that lands after a suspension
point checks the claim it was made under and drops itself if that claim has been superseded.
Dropped, not reordered — a dropped write cannot come back later. The check sits immediately before
the write with no suspension point between them, and both run on the main dispatcher, which is the
atomicity argument the old comment only asserted.
Cancelling the old observer was never sufficient, and the fix does not rest on it (both
ViewModels still cancel, because a live collector is a leak):
Job.cancelis a request, honoured at the next suspension point. A collector that has alreadyresumed and is on its way to
_state.value = …has none left, so the write lands anyway.observation until after it has been launched.
The check sits ahead of the
when, not merely ahead of the assignment: the SUCCEEDED branch takesownership of the staged file, and a superseded observation must not do that either.
JoinViewModelhad the identical shape and nothing watching it, so it gets the same fix. Itspick dispatcher becomes injectable for the same reason
ConversionViewModel's already was (#94):without that seam there is no way to ask what happens while a pick is still in flight.
Two changes, not one
The observation token is #49's fix. The pick-side guards are a second defect of the same class,
found while fixing it and closed alongside:
onInputPicked's two writes both land after a hop, sotwo taps in quick succession put the loser's file on screen if its query came back second. Measured,
not assumed — see the mutations.
The tests are the evidence, not CI
The device test catches this about once in 45 leg-attempts. One green CI run proves almost nothing
about this race. What proves it is that the JVM reproduces it deterministically, and did so before
any ownership code existed.
ReattachmentOwnershipTest/JoinReattachmentOwnershipTestpark the pick on a dispatcher the testowns, so "issued but not yet written" is a state the test sits in rather than one it races for.
reattach's continuation comes back from a real IO thread and can only be a message posted to amain looper Robolectric leaves paused, so the pick is always issued before the guard runs.
Neither test rests on a settle window: a second ViewModel with nothing to supersede it reattaches
through the same code, and when it has arrived the whole query→guard→observe→write path has
demonstrably run. The one under test started first, so it has had at least as long.
The Join test was written first and confirmed red with only the dispatcher seam added and no
ownership code:
The convert one reproduces the CI shape exactly:
Mutations
16 applied, each against the whole JVM suite, each restored after.
Bite (12):
if (!ownership.stillHeldBy(token)) return@collectinConversionViewModel.observeReattachmentOwnershipTest > a conversion found while the user was picking never reaches the screenJoinViewModel.observeJoinReattachmentOwnershipTest > a join found while the user was picking never reaches the screentoken = token→token = ownership.currentatobserve(...)inConversionViewModel.reattachJoinViewModel.reattach_state.value = ConversionState.Ready(file)PickOwnershipTest > the slower of two picks does not land on top of the faster onePickOwnershipTest,FailedSaveRetryTest,ConversionViewModelNamingTest,ConversionViewModelCleanupTest,MissingStagedFileTest_state.value = JoinState.Ready(files)JoinPickOwnershipTest > the slower of two selections does not land on top of the faster oneif (true) return@collectinConversionViewModel.observe— the reverse checkJoinViewModel.observeownership.claim()→ownership.currentinconvert()M8/M9 are the "does legitimate reattachment still land" check: dropping every observation reddens
a conversion nobody has superseded still reaches the screen, its join twin, and the existingcleanup/naming/save suites.
Could not fail — two different reasons, and neither is "it bites, trust me":
Not measurable by this harness. Robolectric runs
Dispatchers.Main.immediateinline, so a claimmoved inside a
launchstill happens synchronously and the mutation is a no-op here:val token = ownership.currentmoved insidereattach'slaunch. Superseded byM15/M16, which are the semantic version and do bite.
ownership.claim()moved insideonInputPicked'slaunch. The placement is defensiveagainst a dispatcher that does not run inline; this harness cannot produce one.
Genuinely untested lines. No test constructs the situation:
ownership.claim()fromreset()in either ViewModel.ownership.claim()→ownership.currentinjoin().M11/M14 are unreddenable only because
save()is exempt, and those are the same fact: the onlydeferred write that could land on top of a
reset()issave's. That gap is real and now filed as#123 — a save that finishes after "Start over" puts
Savedback on a screen the user cleared.It is not fixed here because closing it means choosing between "report nothing for a file that may
genuinely have been published" and "report success for work the user dismissed", which is a question
about what the screen offers during a save, not about this race. M13 cannot bite because
join()isonly reachable from
Ready, so the join pick's single deferred write has always landed by then; theclaim is there for consistency with
convert().Not changed
The instrumented tests are untouched.
ReattachGuardsTeststill covers the case the plain guarddoes catch — a pick that has already landed — and that guard stays, with a corrected comment.
Gate
assembleDebug+testDebugUnitTest+compileDebugAndroidTestKotlin+ktlintCheck+detekt+lintDebug, all green locally.🤖 Generated with Claude Code