R37 — A reattachment can overwrite a pick the user has already made (surfaces as ReattachOnLaunchTest flake) #49
Closed
opened 2026-08-23 04:53:26 +00:00 by JMR-dev
·
8 comments
No Branch/Tag Specified
main
fix/102-picker-back-press-overshoot
fix/268-saf-picker-determinism
feat/ogg-vorbis-libvorbis
feat/expedited-conversion-work
fix/gate-cache-in-worktrees
chore/gate-runs-shellcheck
test/publish-delete-arm-real-provider
docs/e8-instrumented-coverage
docs/api37-carrier-count-drift
docs/e7-second-constraint
test/publish-to-a-real-saf-destination
fix/api37-report-match-line
fix/api37-task-snapshot-crash
test/join-failure-message-on-device
docs/e2e-read-findings-e7
test/cancelling-a-running-export
test/reattach-to-a-running-job
test/content-uri-reaches-ffmpeg
fix/launcher-wiring-waits-for-the-pick
fix/cancel-tests-need-a-slower-encode
test/cancelling-a-running-join
test/cancelling-a-running-session
test/notification-cancel-action
test/ffmpeg-progress-is-observed
test/fallback-asserts-the-path
test/flac-and-opus-assert-their-format
docs/e2e-read-findings
docs/wave4-coverage-numbers
fix/injectable-startup-sweep-scope
test/session-outcome-seam
test/launcher-callback-identity
test/theme-follows-system-dark
test/audio-drop-arm
fix/rotation-waits-for-recreation
fix/convert-guards-on-ready
test/retry-save-mime
test/hardware-progress-reaches-workmanager
test/ffprobe-mapping-seam
test/device-codec-enumeration-seam
test/unknown-container-row
test/null-message-fallbacks
test/cancel-reaches-workmanager
docs/coverage-wave3-recovery
test/concat-engine-seam
docs/coverage-wave3
test/mediaprobe-merge-seam
test/adaptive-shell-wiring
test/aac-audio-args
test/notification-progress-text
test/media3-muxer-guard
test/hardware-fallback-and-cancellation
test/unprobeable-join-clip
test/one-branch-outcomes
test/foreground-type-regimes
fix/bound-wedge-diagnostics
docs/coverage-wave2
test/screen-wiring
test/viewmodel-setters
test/join-state-mapping
test/conversion-state-mapping
test/dedupe-user-messages
fix/restore-stack-merges
test/refused-jobs
test/concatworker-failure-arms
test/container-capabilities-audio
test/readspec-enum-fallbacks
test/outputpublisher-seams
test/mediaprobe-track-seam
test/outputpublisher-partial-branches
test/fake-provider-scaffolding
docs/coverage-read-findings
chore/gitignore-kotlin
test/bound-the-hangs
docs/coverage-remeasure
ci/baseline-counter-precision
fix/invalid-suggestion-chip
ci/wedged-leg-report
fix/reattachment-overwrites-pick
test/theme-live-branches
fix/failed-save-retry
fix/empty-composition-crash
ci/advisory-failure-report
docs/seven-run-counts
test/release-permission-guard
ci/build-workflow-permissions
docs/api37-point-release
docs/benchmark-populate-path
fix/dead-assertion-probe-test
ci/actionlint
test/device-codecs-encode-consequence
fix/sdkmanager-pipefail
docs/readme-restart-claim
fix/saf-picker-root-discovery
fix/probe-dispatcher-seam
test/media3engine-mime-tables
test/mediaprobe-pure-helpers
fix/codec-vocabulary-drift
docs/robolectric-rationale-correction
docs/api37-advisory-counts
test/r38-8-saf-e2e
test/r38-7-join-states
test/r38-6-conversion-states
test/r38-5-state-seam
fix/jacoco-robolectric-coverage
docs/instrumented-tests-correction
test/r38-2-filecard
test/r38-4-advanced-picker
test/r38-3-pickers
tools/file-issue-script
tools/api-37-emulator
fix/review-app-gaps
docs/review-corrections
No results found.
Labels
Clear labels
above-cut
accessibility
backlog
bug
confirmed
documentation
duplicate
enhancement
good first issue
help wanted
invalid
plausible
question
sev:high
sev:low
sev:medium
wontfix
Worked autonomously overnight: local, JVM-verifiable, no product decision
Barrier affecting people with disabilities
Held for manual review: product/UX call, CI/workflow, hardware, or unverifiable here
Something isn't working
Reviewer demonstrated the defect
Improvements or additions to documentation
This issue or pull request already exists
New feature or request
Good for newcomers
Extra attention is needed
This doesn't seem right
Reviewer could not fully demonstrate it; treat as unproven
Further information is requested
High severity
Low severity
Medium severity
This will not be worked on
Milestone
No items
No Milestone
Projects
Clear projects
No projects
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: JMR-dev/LibreMediaConverter#49
Reference in New Issue
Block a user
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.
Observed on CI during the overnight run, not by the review. Filed because everything gets a ticket.
R37 —
ReattachOnLaunchTest.doesNotOverwriteAPickTheUserHasAlreadyMadeis flaky on CIseverity: medium
verdict: CONFIRMED (observed red once; green on every other run of the same commit range)
where: app/src/androidTest/java/org/libremediaconverter/convert/ReattachOnLaunchTest.kt
scenario: E2E API 35 on PR #47 reported
Tests 59/57 completed. (2 skipped) (1 failed)with thistest FAILED. PR #47 changes zero files under
app/— docs andtools/only — so itcannot be the cause. The same test passed on PR #8 (which introduced it), on PR #45,
and on PR #48.
evidence: Job https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32617686626/job/97140992869
Execute …doesNotOverwriteAPickTheUserHasAlreadyMade: FAILED, runnerMemAvailable: 1162172 kB, native-crash section empty (so not an emulator abort).why it is racy:
The test asserts that a user's pick, made while the WorkManager tag query is in
flight, survives a late reattachment. On the JVM that race is now made deterministic —
ReattachGuardsTest(#48) installs a holdable task executor so the query cannotcomplete until the pick has landed, and asserts the brake actually gripped. The
instrumented version has no such control: it races a real WorkManager against a real
dispatcher on a shared CI runner, so it is timing-dependent by construction.
fix: Either give the instrumented test the same determinism (a controllable task executor),
or delete it as redundant now that
ReattachGuardsTestpins the same behaviourdeterministically on the JVM — the review already noted this instrumented test was
"not executed, only read".
risk: Deleting coverage that looks real is the wrong reflex if the JVM twin does not actually
cover the same guard. Confirm the equivalence before removing anything.
Cut:
backlog— a flaky test in CI is exactly the thing not to "fix" unattended at 4am on oneobservation. The choice between hardening and deleting is a judgment call.
🤖 Generated with Claude Code
Another occurrence, on a PR that touches no Kotlin at all (#69 — a shell script, a workflow step and
CLAUDE.md). That makes the "unrelated diff" evidence stronger: this fails on branches whose contents cannot plausibly affect it.Run 32777393771, E2E API 35, failing at test 13 or so of 57:
Note
expected null, but was:<Converted>— the guard did not merely lose a race, it let a finished job take a screen the user had already moved on from. Worth checking whether the flake is a timing artefact of the test or the guard genuinely having a window.Frequency is now the problem, not just the noise. This is the third sighting today, all on unrelated branches. #52 is decomposed into eight PRs (#57–#64) and each one runs this leg, so at the current rate it will redden several of them and cost a re-run each time. That moves it from "annoying" toward "blocking the chain".
One environment detail from this run, in case it is a clue rather than background noise — the job logged adb trouble during startup before the suite began:
The suite then started and 56 of 57 passed, so this did not stop the run — but a slow or restarted adb is exactly the kind of thing that would widen a reattachment race.
Data point, with its limits stated: this has not recurred today.
I logged four sightings earlier and said the rate was turning it into a chain blocker. Since then roughly a dozen more CI runs have gone through on six PRs, and
ReattachOnLaunchTest.doesNotOverwriteAPickTheUserHasAlreadyMadehas appeared in none of the failures. Every failing gating leg in that window has beenSafPickerRoundTripTest(#93) instead.What this is not. A few hours of quiet is not evidence a flake is gone, and nothing in the codebase changed that would plausibly fix it — the reattachment guard is untouched since the sightings. Do not close this on the strength of it.
What it is worth. Two things:
gh run rerun --failed. A rerun that goes green tells you nothing about which test failed first, so once #93 is fixed it is worth re-checking whether this is still live rather than assuming the quiet continues.The substance of the finding is unchanged and still worth fixing: the assertion is
expected null, but was:<Converted>, so the guard let a finished job take a screen the user had already moved on from — not merely a lost race. TheUnable to connect to adb daemonstartup noise in the run that produced it is still the most promising lead, since a slow or restarted adb would widen exactly that window.It has recurred, and the reason it went quiet is now the interesting part.
ReattachOnLaunchTest.doesNotOverwriteAPickTheUserHasAlreadyMadefailed the E2E API 36 gating leg on PR #110 — a diff of exactly one file,docs/api-37-emulator-crash.md. Nothing in that change can reach an instrumented test.This is what the earlier comment predicted
I wrote, when noting it had gone quiet:
#93 was fixed and merged (#96) a few hours ago. The SAF failures stopped. This one reappeared. That
is the sequence the comment anticipated, and it is worth recording as a confirmed mechanism rather
than a coincidence: a dominant flake hides the ones beneath it, because the recovery for both is
the same rerun and the rerun erases which came first.
What it does not mean
Not that the rate went up. Two sightings today with a fix for a louder flake in between is not a
trend, and I have twice this session read a cluster as a rate and had to correct it. The honest
summary is: this was live before #93, it is live after #93, and the interval in between tells us
nothing.
The substance is unchanged
The assertion is still
expected null, but was:<Converted>— the guard let a finished job take ascreen the user had already moved on from, which is not merely a lost race. And the earlier
observation still stands as the most promising lead: the run that produced the first sighting logged
Unable to connect to adb daemonthree times during startup, and a slow or restarted adb wouldwiden exactly that window.
That lead is now stronger, not weaker. #102 collects three separate CI failure modes that are all
"system services stop answering under load", and #96 proved that shape once already. A reattachment
race widened by a stalled adb is the same illness in a fourth place. Worth checking whether these
sightings carry the load signature before treating this as a logic bug in the guard.
Measured rate, and a correction to how it was being measured
Census of every gating E2E leg-attempt since 2026-08-24 (advisory job excluded), classified by
reading each failing job's log and anchoring the test name to the
FAILEDmarker on its own line.The method is the first finding. Counting runs whose conclusion is
failuresees 19 failures.Counting leg-attempts sees 45. A leg that is re-run to green vanishes from the run-level count
entirely, so more than half of all CI failures today were invisible to the way this was being
counted. Every number below is per leg-attempt.
SafPickerRoundTripTest(#93) — picker and/or rotation<no test named>— abort or infra, nothing reported failedfailsOnPurposeToProveTheAdvisoryReportFires— deliberate probe for #83doesNotOverwriteAPickTheUserHasAlreadyMade(this ticket)transcodesH264ToH265AndReportsProgress(#102)routesAFastMp4JobByDeviceCapabilityDenominators: 45 failures / 400 leg-attempts overall; 13 / 140 since #96 landed at
2026-08-25T13:42Z.
This flake is still live, and it is not emulator-image-specific
Three occurrences, all on attempt 1, all passing on re-run:
Two of the three are after #96, so the masking hypothesis holds: #93 was consuming the failure
budget, and this surfaced once that stopped. Rate since #96 is 2/140 leg-attempts (~1.4%).
Three different API levels is the new information. #108 is confined to API 37 and #93 was worst
on 34; this one has now appeared on 33, 35 and 36. That rules out an emulator-image quirk and points
at a genuine race between the reattach sweep and the pick the test has already made — which is what
the ticket's own scenario describes.
ReattachOnLaunchTest.kt:219/:228is where it lands.Correcting an earlier claim on this ticket
I previously recorded that this test "recurred on a docs-only PR after #96 landed". That was wrong
twice: the run I meant (32813816792,
docs/readme-restart-claim) was eight hours before #96merged, and its failure was
SafPickerRoundTripTest, not this test. The conclusion happens tosurvive — the two occurrences in the table above are genuinely post-#96 — but it was not what I had
looked at, and the evidence has been replaced with evidence that was.
Repro note for whoever takes this
Historical attempts cannot be read with
gh run view --job <id> --log; it resolves by run and servesthe latest attempt, so a re-run silently hands you a green log for a red attempt. Use
gh api --allow-escape-sequences /repos/{owner}/{repo}/actions/jobs/{job_id}/logswith the job idfrom
/actions/runs/{run}/attempts/{n}/jobs. Without--allow-escape-sequencesgh returns nothingand exits 0.
This is not a flaky test. It is a product race, and the test is right.
I read the assertion message rather than the failure count. From run
32918773988attempt 1, API 33:The state that took the screen is the old finished job, not a test artefact. The user picked
picked.mp4, the screen showed it, and then a reattachment to a previous conversion replaced it.That is the exact thing
doesNotOverwriteAPickTheUserHasAlreadyMadeexists to forbid, and it is auser-visible defect: open the app, pick a file, and watch a stale conversion from an earlier session
take the screen away from you.
The interleaving
ConversionViewModel.reattach()guards ownership at line 222:and the comment immediately above it says:
There is no assignment below. There is a call to
observe(...), andobserveis:The write happens in a different coroutine, which must suspend on
collectbefore it can emitanything. So the guard runs at time T and the write lands at T+n, with the whole first WorkInfo
emission in between. The failing order is:
init→reattach()suspends insideReattachment.choose(a WorkManager query).onInputPickedsuspends inwithContext(pickDispatcher) { InputQuery.describe(...) }.chooseresumes. Guard reads_state.value— stillIdle, because step 2 has not written yet.Guard passes.
activeWorkIdis set,observe(...)launches,reattach's body returns.onInputPickedresumes →_state.value = Ready(picked.mp4). The user owns the screen.observe's collector receives its firstWorkInfo(SUCCEEDED) →_state.value = Converted(holiday).Step 5 overwrites step 4. This is a check-then-act with the act deferred into another coroutine, and
the comment asserting it cannot interleave is what made it hard to see.
onInputPickedis unguarded in the other direction too — it writesReadywithout consultingactiveWorkId— so whichever coroutine resumes last wins.Why it looks like a flake
The window is exactly the gap between
observe()being called and its first emission arriving.That is small on a fast machine and wide on a loaded CI runner, which is why it reads as
infrastructure noise. Measured: 3 occurrences in 400 gating leg-attempts, on API 33, 35 and
36 — three different images, which is what rules out an emulator quirk. Every occurrence was on
attempt 1 and passed on re-run, which is the signature of a race, not a broken test.
What a fix has to decide
The test encodes the answer already: the user's pick wins. The mechanism needs to make that true
rather than probable — a generation token that
observechecks before every_state.valuewrite isthe shape that fits this codebase, since
activeWorkIdis already the thing being tracked and isalready set synchronously at step 3.
Whatever lands, the comment at lines 219–222 must go with it. It is not merely stale; it states the
opposite of what the code does, and it is the reason this sat as "flaky CI" instead of a race.
Do not fix this by relaxing the test. It is one of the few tests in the suite that has caught a
real concurrency defect.
JoinViewModelhas the same race, and nothing is watching itIdentical structure, same defect:
ConversionViewModelJoinViewModelobserve(...)calledviewModelScope.launch { …collect… }Same check-then-act, same deferred write, same outcome: a reattachment can land on top of files the
user has just picked.
The difference is that nothing catches it.
ReattachOnLaunchTestcoversreattachesToAJoinThatFinishedWhileTheViewModelWasGone— that reattachment works — but there isno Join counterpart to
doesNotOverwriteAPickTheUserHasAlreadyMade. The converter side has beentelling us about this defect for two days at roughly 1-in-130; the join side has been silent because
no one asked it.
So the fix is two changes and two tests, not one of each. The missing Join test should be written
before the fix and confirmed to fail — otherwise it is a test written against code that already
passes, which is the vacuous-green failure mode this repo has measured before (9 of 46 mutations,
five over a completely unguarded path).
Fourth occurrence, fourth API level — and it is now costing re-runs on unrelated PRs
2026-08-26T02:22Z, run32922100069attempt 1, E2E API 35, on #116 (a PR about the Failed-savestate, which touches none of this):
Running tally, all on attempt 1 and all green on re-run:
Three of the four are since #96, and the spread is now API 33, 35 and 36. That is not an image
quirk, and the diagnosis above says why it is not: the ownership guard in
reattach()runs beforeobserve()defers the write into a second coroutine, so whichever of the two resumes last wins.The practical cost has changed too. This is no longer a curiosity on its own PR — it is now failing
gating legs on unrelated changes and being re-run by hand, which is exactly how a real defect gets
naturalised as "CI being flaky".
Worth recording: the report added by #111 named the failing test in one line here, with no log
archaeology. That is the second time it has paid for itself since merging.
Post-fix check: zero recurrences, and that does not yet mean anything
Census of every gating E2E leg-attempt since #124 merged (
2026-08-26T03:58:57Z):hasReadColorBufferDmaabort on API 37 taking downSafPickerRoundTripTest.pickingAFileThroughTheSystemPickerFillsInTheFileCard.doesNotOverwriteAPickTheUserHasAlreadyMade: 0 occurrences.That is not evidence the fix works, and I want it on record before someone later reads it as such.
The pre-fix rate was 2 in 140 post-#96 leg-attempts, ≈1.4%. At that rate:
Seeing zero in 20 is what you would expect three times in four with no fix at all. It is
consistent with the fix working and equally consistent with it doing nothing; it discriminates
between them not at all.
CI becomes worth quoting somewhere around 200 leg-attempts — roughly 40 more full runs — and even
then it is a probabilistic argument, not a proof.
The evidence that actually closed this ticket is the deterministic JVM reproduction: the Join test
failed before any production change, quoted verbatim in #124, with the pick parked on a dispatcher the
test owns so the interleaving is forced rather than raced. Twelve of sixteen mutations bite. That is
what makes the fix trustworthy, and it did not need CI to say so.
Recording the arithmetic so the next person to look has a threshold instead of an impression.