Let the user's pick keep the screen a reattachment was about to take #124

Merged
JMR-dev merged 4 commits from fix/reattachment-overwrites-pick into main 2026-08-26 03:58:57 +00:00
JMR-dev commented 2026-08-26 03:39:37 +00:00 (Migrated from github.com)

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

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