refactor(download): pin the refusal branch, and stop the KDoc overstating three things #11

Merged
julian merged 4 commits from fix/claim-final-name-followups into main 2026-09-02 13:06:35 +02:00
Owner

Closes #10 — the four non-blocking items from @thisilike's approval of #7. One test file, one production file; the production change is comments, a named atomicMove, and a defaulted parameter production does not pass.

1. The refusal branch, pinned — in both of its shapes

claimFinalName's fill-refused branch — the strike, target.delete(), the exhaustion, and the cause riding along on IllegalStateException("cannot claim a name …", refused) — was added in answer to the first review round of #7 and had no test on it. (Not "the one part of the function nothing pinned": the createNewFile"cannot write to …" branch at SchemaViewModel.kt:430-436 still has none, and pinning it wants an unwritable directory, which is not the same thing as a refused move.)

A refusal is not something a host gives on demand for a fill whose source exists. On Linux nothing here produces one — unlink of an open file just works, and source and target share a filesystem — so it is exercised only incidentally, on the Windows desk, by a scanner holding the fresh placeholder. The reviewer's instrumented run in #7 recorded zero refusals across 3000 rounds × 16 threads; the committed concurrency test is repeat(200) with n = 8 and produces none either.

So it is pinned in two tests, from two directions:

a fill refused every round exhausts the loop and leaves nothing behind — a temp that does not exist refuses every fill deterministically, on any host, and drives the loop to its backstop. Asserts the message (assertEquals over the part before " in ", so a failure prints what it got); the cause (java.nio.file.NoSuchFileException, spelled at the use site because kotlin.io has a same-named class in the default imports); the strike, per round; and that an exhausted claim released every name it took rather than leaving 1000 zero-byte "downloads".

a refused fill releases its claim, and the next candidate lands the bytes — the shape a device hits: one fill refused, the next let through. Asserts the claim was released, the bytes landed one number past it, and the parts directory is empty. This is the half the exhaustion test cannot reach, because there the loop never returns.

Verified by mutation rather than by reading it, at c855bed on Linux:

Mutant Result
delete lost += candidate from the refusal branch killed — last candidate refused was a.txt, and expected:<a (2).txt> but was:<a.txt>
if (lost.size < 1) lost += candidate killed — last candidate refused was a (2).txt
target.delete(); if (temp.exists()) target.createNewFile() killed — expected:<[.parts, a (2).txt]> but was:<[a (2).txt, a.txt, .parts]>
delete target.delete() killed — 1000 corpses, [a (5611506248938).txt, a (999).txt, …]
drop refused from the throw killed — cause was null

The exhaustion test is also the only thing pinning repeat(1000). An earlier revision of this description said the opposite — that the bound was held by concurrent claims … and not claimed here — and review disproved it: repeat(200) leaves the concurrency test green and fails the exhaustion test, because the assertFalse at the end only holds while the bound exceeds the 999 uniqueName counts to. Worse, it reports a shrunk bound as a strike failure (last candidate refused was a (200).txt), which sends the next reader to the wrong line. That is now said in the test's own comment rather than denied here.

2. The accepted loss, as wide as it actually is

The KDoc said the stranded zero-byte placeholder needs a process death. It does not: target.delete()'s result is ignored, and the scanner that refuses the fill can refuse the release too. Nor is the set closed at two — anything thrown between the claim and the release that the function does not catch strands the same corpse, an unchecked throw out of the move included. The loss is now "a claim this transfer never manages to release", under a name it claimed (after a failed release the loop strikes that number and lands elsewhere, so the corpse is not under this transfer's final name), and the reason the delete's result is not worth branching on is stated rather than implied.

Same cause, second consequence: concurrent claims …' third assertion (assertEquals(landed.keys, downloads.list()!!.toSet())) can flake honestly on the Windows desk. Said in the test and left strict — the flake is desk-only, and on the one host CI runs on it has not produced it. The new exhaustion test's no-corpse assertion is exactly as desk-flaky, for the same reason, and now carries the same note.

3. This path only ships to API 26–28

claimFinalName is reached from downloadToAppDownloads, the else of SDK_INT >= Q. From Android 10 a download goes through downloadViaMediaStore: the insert settles a pending row under the fallback name, and the served name is applied afterwards by resolver.update — the call that can collide, with its own fallback and read-back (displayNameOf). Each step is a single atomic operation whose outcome is read back rather than assumed, so there is no check-then-claim in that path to race. The read-back is a query, and a query that fails reports the name asked for — a saved file under a label that may be wrong, never a saved file lost. The KDoc's "two pages on the back stack" now says which device that is: Android 8 or 9.

4. Where these tests run

CI runs on one host, cth-ubuntu-latest, in both workflows. The ATOMIC_MOVE argument never needed a count of hosts and no longer carries one: on Windows renameTo refuses an existing target outright, so a run there would exercise a different move than the one that ships.

The move as a parameter

Item 1's second test needs a refusal on cue, and no host gives one for a fill whose source exists. So the fill is a parameter, and its default is a named function rather than a lambda written inline:

internal fun atomicMove(from: File, to: File) {
    Files.move(from.toPath(), to.toPath(), StandardCopyOption.ATOMIC_MOVE)
}

internal fun claimFinalName(
    dir: File,
    name: String,
    temp: File,
    move: (File, File) -> Unit = ::atomicMove,
): File

Named, because the one test that replaces the move then delegates to it for the rounds it lets through instead of restating Files.move(…, ATOMIC_MOVE). A copy of that line in the test is a copy that can drift, and it would drift in exactly the direction the KDoc's central argument is about: REPLACE_EXISTING there is what reopens the race, and it is caught only because the concurrency test goes through the default. downloadToAppDownloads passes nothing, so the production call site is byte-identical in behaviour. The KDoc says why the seam exists, so the next reader does not take it for indirection with no purpose.

Rework after the 7c18ffd review

  • Blocking 1 — the strike was pinned per run, not per round; if (lost.size < 1) survived. One strike per round over 1000 rounds walks uniqueName off the 2..999 it counts to, so the last candidate must be the nanoTime fallback. Asserted as two exclusions (not a.txt, not a number uniqueName counts to) rather than as a value.
  • Blocking 2 — "no corpse" was pinned only in a state the shipping path cannot reach. Took the parameter rather than the narrowed sentence; the leak mutant now dies, and the return-after-refusal path ("the next candidate is unencumbered") has an assertion for the first time.
  • Blocking 3 — the two-host census removed from the KDoc rather than relocated.
  • Non-blocking — MediaStore read-back can fail back to the predicted name; the accepted-loss set is open, not two; "cannot produce it" → "has not produced it"; a Linux fill is not refused because of one filesystem, not one directory; the strict no-corpse assertion carries the flake note; the exhaustion test says so in its name; [downloadToAppDownloads] does not resolve from a top-level KDoc and is spelled instead; the "one part nothing pinned" claim corrected above and in #10; the "3000 rounds × 16 threads" attributed to the instrumented run it came from, with the committed test's own numbers alongside.

Rework after the c855bed review

The three sentences the approval asked for before merge, and the four nits.

  • The bound claim was false in both halves — corrected in §1 above and moved into the test's own comment, with the reproduction (repeat(200) fails the exhaustion test alone).
  • SchemaViewModel.kt contradicted itself about the backstop. "A state downloadToAppDownloads never reaches" sat three lines below "a directory that grants creates but refuses every rename exhausts the loop". Production can reach the backstop; what it cannot produce is a missing temp, which is what the sentence says now.
  • "a refusal is not something a host can be asked for" was a new absolute, contradicted by the test's own "refuses every fill deterministically, on any host" twelve lines later. The claim the seam needs is narrower and holds: no host refuses a fill whose source exists.
  • The injected lambda restated the production default instead of delegating to it — hence atomicMove above.
  • No default-move fixture was laid out the way production lays it out. a claimed name lands the bytes … now keeps its partials in PARTS_DIR, so the cross-directory move §4 leans on has a fixture behind it; every other call site keeps its partial in downloads itself.
  • The exhaustion test's same-filesystem parenthetical was about production, not about its own fixture, whose partial is in downloads and never exists. Attached to the right one.
  • A comment wrap broke mid-identifier across three lines.

Gates

spotlessCheck, lintDebug and testReleaseUnitTest — the three CI runs — green locally at the head, forced with --rerun-tasks. DownloadNamingTest 14/14, 117/117 overall. Both mutations quoted in the rework section were run rather than reasoned about: repeat(200) fails the exhaustion test and nothing else, and atomicMoveREPLACE_EXISTING still fails the concurrency test, so extracting the default cost no coverage.

versionName 0.31.1 → 0.31.2: the diff is no longer comment-only — a signature change is an invisible internal, which CLAUDE.md puts under patch. The title moved from test(download): for the same reason.

🤖 Generated with Claude Code

Closes #10 — the four non-blocking items from @thisilike's approval of #7. One test file, one production file; the production change is comments, a named `atomicMove`, and a defaulted parameter production does not pass. ## 1. The refusal branch, pinned — in both of its shapes `claimFinalName`'s fill-refused branch — the strike, `target.delete()`, the exhaustion, and the cause riding along on `IllegalStateException("cannot claim a name …", refused)` — was added in answer to the first review round of #7 and had no test on it. (Not "the one part of the function nothing pinned": the `createNewFile` → `"cannot write to …"` branch at `SchemaViewModel.kt:430-436` still has none, and pinning it wants an unwritable directory, which is not the same thing as a refused move.) A refusal is not something a host gives on demand for a fill whose source exists. On Linux nothing here produces one — unlink of an open file just works, and source and target share a filesystem — so it is exercised only incidentally, on the Windows desk, by a scanner holding the fresh placeholder. The reviewer's instrumented run in #7 recorded zero refusals across 3000 rounds × 16 threads; the committed concurrency test is `repeat(200)` with `n = 8` and produces none either. So it is pinned in two tests, from two directions: **`a fill refused every round exhausts the loop and leaves nothing behind`** — a `temp` that does not exist refuses every fill deterministically, on any host, and drives the loop to its backstop. Asserts the message (`assertEquals` over the part before `" in "`, so a failure prints what it got); the cause (`java.nio.file.NoSuchFileException`, spelled at the use site because `kotlin.io` has a same-named class in the default imports); the strike, per round; and that an exhausted claim released every name it took rather than leaving 1000 zero-byte "downloads". **`a refused fill releases its claim, and the next candidate lands the bytes`** — the shape a device hits: one fill refused, the next let through. Asserts the claim was released, the bytes landed one number past it, and the parts directory is empty. This is the half the exhaustion test cannot reach, because there the loop never returns. Verified by mutation rather than by reading it, at `c855bed` on Linux: | Mutant | Result | | --- | --- | | delete `lost += candidate` from the refusal branch | killed — `last candidate refused was a.txt`, and `expected:<a (2).txt> but was:<a.txt>` | | `if (lost.size < 1) lost += candidate` | killed — `last candidate refused was a (2).txt` | | `target.delete(); if (temp.exists()) target.createNewFile()` | killed — `expected:<[.parts, a (2).txt]> but was:<[a (2).txt, a.txt, .parts]>` | | delete `target.delete()` | killed — 1000 corpses, `[a (5611506248938).txt, a (999).txt, …]` | | drop `refused` from the throw | killed — `cause was null` | **The exhaustion test is also the only thing pinning `repeat(1000)`.** An earlier revision of this description said the opposite — that the bound was held by `concurrent claims …` and not claimed here — and review disproved it: `repeat(200)` leaves the concurrency test green and fails the exhaustion test, because the `assertFalse` at the end only holds while the bound exceeds the 999 `uniqueName` counts to. Worse, it reports a shrunk bound as a *strike* failure (`last candidate refused was a (200).txt`), which sends the next reader to the wrong line. That is now said in the test's own comment rather than denied here. ## 2. The accepted loss, as wide as it actually is The KDoc said the stranded zero-byte placeholder needs a process death. It does not: `target.delete()`'s result is ignored, and the scanner that refuses the fill can refuse the release too. Nor is the set closed at two — anything thrown between the claim and the release that the function does not catch strands the same corpse, an unchecked throw out of the move included. The loss is now "a claim this transfer never manages to release", under **a name it claimed** (after a failed release the loop strikes that number and lands elsewhere, so the corpse is not under this transfer's final name), and the reason the delete's result is not worth branching on is stated rather than implied. Same cause, second consequence: `concurrent claims …`' third assertion (`assertEquals(landed.keys, downloads.list()!!.toSet())`) can flake honestly on the Windows desk. Said in the test and left strict — the flake is desk-only, and on the one host CI runs on it has not produced it. The new exhaustion test's no-corpse assertion is exactly as desk-flaky, for the same reason, and now carries the same note. ## 3. This path only ships to API 26–28 `claimFinalName` is reached from `downloadToAppDownloads`, the `else` of `SDK_INT >= Q`. From Android 10 a download goes through `downloadViaMediaStore`: the insert settles a *pending* row under the fallback name, and the served name is applied afterwards by `resolver.update` — the call that can collide, with its own fallback and read-back (`displayNameOf`). Each step is a single atomic operation whose outcome is read back rather than assumed, so there is no check-then-claim in that path to race. The read-back is a query, and a query that fails reports the name asked for — a saved file under a label that may be wrong, never a saved file lost. The KDoc's "two pages on the back stack" now says which device that is: Android 8 or 9. ## 4. Where these tests run CI runs on one host, `cth-ubuntu-latest`, in both workflows. The `ATOMIC_MOVE` argument never needed a count of hosts and no longer carries one: on Windows `renameTo` refuses an existing target outright, so a run there would exercise a different move than the one that ships. ## The move as a parameter Item 1's second test needs a refusal on cue, and no host gives one for a fill whose source exists. So the fill is a parameter, and its default is a named function rather than a lambda written inline: ```kotlin internal fun atomicMove(from: File, to: File) { Files.move(from.toPath(), to.toPath(), StandardCopyOption.ATOMIC_MOVE) } internal fun claimFinalName( dir: File, name: String, temp: File, move: (File, File) -> Unit = ::atomicMove, ): File ``` Named, because the one test that replaces the move then **delegates** to it for the rounds it lets through instead of restating `Files.move(…, ATOMIC_MOVE)`. A copy of that line in the test is a copy that can drift, and it would drift in exactly the direction the KDoc's central argument is about: `REPLACE_EXISTING` there is what reopens the race, and it is caught only because the concurrency test goes through the default. `downloadToAppDownloads` passes nothing, so the production call site is byte-identical in behaviour. The KDoc says why the seam exists, so the next reader does not take it for indirection with no purpose. ## Rework after the `7c18ffd` review - **Blocking 1** — the strike was pinned per run, not per round; `if (lost.size < 1)` survived. One strike per round over 1000 rounds walks `uniqueName` off the 2..999 it counts to, so the last candidate must be the nanoTime fallback. Asserted as two exclusions (not `a.txt`, not a number `uniqueName` counts to) rather than as a value. - **Blocking 2** — "no corpse" was pinned only in a state the shipping path cannot reach. Took the parameter rather than the narrowed sentence; the leak mutant now dies, and the return-after-refusal path ("the next candidate is unencumbered") has an assertion for the first time. - **Blocking 3** — the two-host census removed from the KDoc rather than relocated. - **Non-blocking** — MediaStore read-back can fail back to the predicted name; the accepted-loss set is open, not two; "cannot produce it" → "has not produced it"; a Linux fill is not refused because of one filesystem, not one directory; the strict no-corpse assertion carries the flake note; the exhaustion test says so in its name; `[downloadToAppDownloads]` does not resolve from a top-level KDoc and is spelled instead; the "one part nothing pinned" claim corrected above and in #10; the "3000 rounds × 16 threads" attributed to the instrumented run it came from, with the committed test's own numbers alongside. ## Rework after the `c855bed` review The three sentences the approval asked for before merge, and the four nits. - **The bound claim was false in both halves** — corrected in §1 above and moved into the test's own comment, with the reproduction (`repeat(200)` fails the exhaustion test alone). - **`SchemaViewModel.kt` contradicted itself about the backstop.** "A state `downloadToAppDownloads` never reaches" sat three lines below "a directory that grants creates but refuses every rename exhausts the loop". Production *can* reach the backstop; what it cannot produce is a missing temp, which is what the sentence says now. - **"a refusal is not something a host can be asked for" was a new absolute**, contradicted by the test's own "refuses every fill deterministically, on any host" twelve lines later. The claim the seam needs is narrower and holds: no host refuses a fill whose source exists. - **The injected lambda restated the production default** instead of delegating to it — hence `atomicMove` above. - **No default-move fixture was laid out the way production lays it out.** `a claimed name lands the bytes …` now keeps its partials in `PARTS_DIR`, so the cross-directory move §4 leans on has a fixture behind it; every other call site keeps its partial in `downloads` itself. - **The exhaustion test's same-filesystem parenthetical was about production**, not about its own fixture, whose partial is in `downloads` and never exists. Attached to the right one. - **A comment wrap broke mid-identifier** across three lines. ## Gates `spotlessCheck`, `lintDebug` and `testReleaseUnitTest` — the three CI runs — green locally at the head, forced with `--rerun-tasks`. `DownloadNamingTest` 14/14, 117/117 overall. Both mutations quoted in the rework section were run rather than reasoned about: `repeat(200)` fails the exhaustion test and nothing else, and `atomicMove` → `REPLACE_EXISTING` still fails the concurrency test, so extracting the default cost no coverage. `versionName` 0.31.1 → 0.31.2: the diff is no longer comment-only — a signature change is an invisible internal, which CLAUDE.md puts under patch. The title moved from `test(download):` for the same reason. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
test(download): pin the refusal branch, and stop the KDoc overstating three things
All checks were successful
PR Checks / check (pull_request) Successful in 2m44s
6b8ec4ad8d
The four non-blocking notes from the #7 review. No behaviour change: one
test and four corrected sentences.

The fill-refused branch — the strike, the release, the exhaustion backstop
and the cause riding along — was added in answer to the first review round
and is the one part of claimFinalName nothing pinned. On Linux a fill is
never refused (unlink of an open file just works), so 3000 rounds of the
concurrency test walk past it; it was exercised only incidentally, on the
Windows desk, by a scanner holding the fresh placeholder. A temp that does
not exist refuses every fill deterministically, on either host, and one
test then covers all four properties. Verified by mutation: dropping
target.delete() fails it, and so does dropping `refused` from the throw.

The KDoc claimed the stranded zero-byte placeholder needs a process death.
It does not — target.delete()'s result is ignored, and the scanner that
refuses the fill can refuse the release too, stranding the same corpse with
no crash involved. The loss is a claim this transfer could not release,
whatever stopped it; that the result is not worth branching on is now said
rather than implied.

It also argued the race from "two pages on the back stack" without saying
where: claimFinalName is the else of SDK_INT >= Q, so it only ships to
API 26-28. Android 10 and up settle the name in MediaStore's insert, which
dedupes atomically and hands the landed name back — no check-then-claim
there to race. Worth having next to the reasoning rather than found again
by whoever touches the Q+ path.

And "on the desk, where these tests run" put them in one place; they run on
the Windows desk and on cth-ubuntu-latest, twice per push to main. The
reason for ATOMIC_MOVE over renameTo is exactly that, so the clause was
undercutting its own argument.

spotlessCheck, lintDebug and testReleaseUnitTest green locally (13/13 in
DownloadNamingTest). No versionName bump: nothing here reaches a device.

Refs #10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
thisilike requested changes 2026-08-24 19:43:17 +02:00
Dismissed
thisilike left a comment

Re-reviewed at 6b8ec4a in a detached worktree, running the gates and the mutations myself rather than taking the description's word for any of them.

What I verified independently, and what held

Claim in the PR Result
spotlessCheck, lintDebug, testReleaseUnitTest green True. All three BUILD SUCCESSFUL locally.
DownloadNamingTest 13/13 True. tests="13" failures="0" errors="0". New test costs 54 ms.
Deleting target.delete() fails the test True. Killed, with expected:<[]> but was:<[a (596442187657244).txt, a (999).txt, …]> — 1000 corpses, exactly the property claimed.
Dropping refused from the throw fails the test True. Killed.
A bare NoSuchFileException would silently resolve to kotlin.io's True, and worth the sentence. Removing the explicit import still compiles and the assertion then fails. Nice catch.
API 26–28 only True. minSdk = 26, VERSION_CODES.Q = 29, and claimFinalName has exactly one production caller, inside the else. Android 8.0 / 8.1 / 9 is right.
No versionName bump Fine. CLAUDE.md gates on "ships user-visible changes"; comments do not reach the APK. (It also lists "invisible internals" under patch, so this is a judgement call either way — not blocking.)

That is a real verification effort and most of it stands up. Two things do not.

Blocking

1. The new test comment states something the repository contradicts. It says the strict assertion is protected "on both hosts CI runs". CI runs on one host — runs-on: cth-ubuntu-latest in both workflows, no Windows runner anywhere in the repo. The PR body contradicts it in the very same sentence ("the one host that never runs CI"). A PR whose entire deliverable is removing overstatements from this file cannot ship a fresh one, particularly when the accurate version of the argument is the stronger one. Inline.

2. The test does not pin the strike, and the PR and #10 both say it does. Mutation-tested: delete lost += candidate from the refusal branch and all 13 tests stay green. It has to survive — with a temp that never exists, every candidate refuses identically, so the loop exhausts, throws with the right message and cause, and leaves an empty directory whether or not it ever numbers past a.txt. Of the four properties named ("the strike, target.delete(), the exhaustion, and the cause"), this test pins three; the fourth is the one that went unmutated. Either pin it or say what the test actually covers. Inline, with the diff and output.

Non-blocking

  1. The MediaStore paragraph attributes the final name to the insert. In downloadViaMediaStore the insert only settles a pending row from fallbackName; the served name is applied by the later resolver.update, which is the call that can collide and has its own fallback and read-back. The conclusion ("no check-then-claim to race") survives; the mechanism as written does not match the function it cites.

  2. "strands the placeholder … under the final name" is true for process death and false for the failed release, where the loop strikes that candidate and lands under a later number.

  3. Two absolutes ("the release only fails on Windows", "on Linux a fill is never refused") are wider than their evidence.

  4. Two nits on the new test: assertTrue on the message hides the message when it fails, and the import rationale sits three lines from the import.

Item 1 is a one-line edit. Item 2 is a decision — add the coverage or narrow the sentence — and I do not mind which, only that the description and the test agree afterwards. The rest of the diff is good work and I would take it as-is.

Re-reviewed at `6b8ec4a` in a detached worktree, running the gates and the mutations myself rather than taking the description's word for any of them. ## What I verified independently, and what held | Claim in the PR | Result | | --- | --- | | `spotlessCheck`, `lintDebug`, `testReleaseUnitTest` green | **True.** All three `BUILD SUCCESSFUL` locally. | | `DownloadNamingTest` 13/13 | **True.** `tests="13" failures="0" errors="0"`. New test costs 54 ms. | | Deleting `target.delete()` fails the test | **True.** Killed, with `expected:<[]> but was:<[a (596442187657244).txt, a (999).txt, …]>` — 1000 corpses, exactly the property claimed. | | Dropping `refused` from the throw fails the test | **True.** Killed. | | A bare `NoSuchFileException` would silently resolve to `kotlin.io`'s | **True, and worth the sentence.** Removing the explicit import still compiles and the assertion then fails. Nice catch. | | API 26–28 only | **True.** `minSdk = 26`, `VERSION_CODES.Q = 29`, and `claimFinalName` has exactly one production caller, inside the `else`. Android 8.0 / 8.1 / 9 is right. | | No `versionName` bump | **Fine.** CLAUDE.md gates on "ships user-visible changes"; comments do not reach the APK. (It also lists "invisible internals" under patch, so this is a judgement call either way — not blocking.) | That is a real verification effort and most of it stands up. Two things do not. ## Blocking **1. The new test comment states something the repository contradicts.** It says the strict assertion is protected "on both hosts CI runs". CI runs on one host — `runs-on: cth-ubuntu-latest` in both workflows, no Windows runner anywhere in the repo. The PR body contradicts it in the very same sentence ("the one host that never runs CI"). A PR whose entire deliverable is removing overstatements from this file cannot ship a fresh one, particularly when the accurate version of the argument is the stronger one. Inline. **2. The test does not pin the strike, and the PR and #10 both say it does.** Mutation-tested: delete `lost += candidate` from the refusal branch and all 13 tests stay green. It has to survive — with a `temp` that never exists, every candidate refuses identically, so the loop exhausts, throws with the right message and cause, and leaves an empty directory whether or not it ever numbers past `a.txt`. Of the four properties named ("the strike, `target.delete()`, the exhaustion, and the cause"), this test pins three; the fourth is the one that went unmutated. Either pin it or say what the test actually covers. Inline, with the diff and output. ## Non-blocking 3. The MediaStore paragraph attributes the final name to the insert. In `downloadViaMediaStore` the insert only settles a pending row from `fallbackName`; the served name is applied by the later `resolver.update`, which is the call that can collide and has its own fallback and read-back. The conclusion ("no check-then-claim to race") survives; the mechanism as written does not match the function it cites. 4. "strands the placeholder … under the final name" is true for process death and false for the failed release, where the loop strikes that candidate and lands under a later number. 5. Two absolutes ("the release only fails on Windows", "on Linux a fill is never refused") are wider than their evidence. 6. Two nits on the new test: `assertTrue` on the message hides the message when it fails, and the import rationale sits three lines from the import. Item 1 is a one-line edit. Item 2 is a decision — add the coverage or narrow the sentence — and I do not mind which, only that the description and the test agree afterwards. The rest of the diff is good work and I would take it as-is.
@ -337,1 +334,3 @@
* past it. The racers above are two ViewModels in one process an
* Only on API 26-28: this is [downloadToAppDownloads], the `else` of
* `SDK_INT >= Q`. From Android 10 a download goes through MediaStore, whose
* insert dedupes the display name atomically and hands the landed name back
Owner

The conclusion holds — there is no check-then-claim in the Q+ path — but the mechanism as described skips the step where a collision actually happens, and the PR claims this was "checked against … the MediaStore function".

In downloadViaMediaStore the insert does not settle the display name. It settles a pending row under fallbackName, which at that point is a guess. The final name is set afterwards, once the response headers have arrived:

put(MediaStore.Downloads.DISPLAY_NAME, name)   // the served name

val renamed = runCatching { resolver.update(uri, finish, null, null) }.getOrDefault(0) > 0
if (!renamed) { finish.remove(MediaStore.Downloads.DISPLAY_NAME);  }

That update is the operation that collides — the code carries a comment saying exactly that ("the served name collides with a row that already exists") — and displayNameOf exists because of it. What makes the path safe is that both the insert and the update are single atomic operations whose outcome is read back, not that the insert decided anything.

Suggested: "…goes through MediaStore, which dedupes the insert and either accepts or refuses the rename to the served name atomically — the landed name is read back rather than predicted, so there is no check-then-claim in that path to race."

The conclusion holds — there is no check-then-claim in the Q+ path — but the mechanism as described skips the step where a collision actually happens, and the PR claims this was "checked against … the MediaStore function". In `downloadViaMediaStore` the insert does not settle the display name. It settles a *pending* row under `fallbackName`, which at that point is a guess. The final name is set afterwards, once the response headers have arrived: ```kotlin put(MediaStore.Downloads.DISPLAY_NAME, name) // the served name … val renamed = runCatching { resolver.update(uri, finish, null, null) }.getOrDefault(0) > 0 if (!renamed) { finish.remove(MediaStore.Downloads.DISPLAY_NAME); … } ``` That `update` is the operation that collides — the code carries a comment saying exactly that ("the served name collides with a row that already exists") — and `displayNameOf` exists because of it. What makes the path safe is that both the insert and the update are single atomic operations whose outcome is read back, not that the insert decided anything. Suggested: "…goes through MediaStore, which dedupes the insert and either accepts or refuses the rename to the served name atomically — the landed name is read back rather than predicted, so there is no check-then-claim in that path to race."
@ -361,3 +369,3 @@
*
* One loss is accepted: a process death between the claim and the rename
* One loss is accepted: a claim this transfer never manages to release
* strands the placeholder as a zero-byte file under the final name, and the
Owner

"under the final name" is now carrying two cases that do not share it.

Process death between claim and rename: the corpse does sit under what would have been the final name.

A delete() that fails: the loop then does lost += candidate and lands the file under a later number. The corpse sits under a candidate this transfer abandoned, and this transfer's final name is a different one. The next clause ("the next transfer of that name numbers past it for good") is right for both; the noun in front of it is right for only one.

"strands the placeholder as a zero-byte file under a name it claimed" covers both without losing anything.

"under the final name" is now carrying two cases that do not share it. Process death between claim and rename: the corpse does sit under what would have been the final name. A `delete()` that fails: the loop then does `lost += candidate` and lands the file under a **later** number. The corpse sits under a candidate this transfer abandoned, and this transfer's final name is a different one. The next clause ("the next transfer of that name numbers past it for good") is right for both; the noun in front of it is right for only one. "strands the placeholder as a zero-byte file under a name it claimed" covers both without losing anything.
@ -367,0 +374,4 @@
* the other, and it needs no crash the scanner that refuses the fill can
* refuse the release too, which is why that result is not worth branching
* on. (Not a shipping concern: Android unlinks an open file, as does Linux,
* so the release only fails on Windows.) No sweep can take the corpse back
Owner

Minor, but this diff is about sentences being exactly as wide as the truth: "the release only fails on Windows" is an absolute about a delete() on an arbitrary directory. Storage unmounted mid-transfer, a read-only remount, a removed SD card — Android can refuse it too, and the same paragraph already accepts that consequence. "does not fail for the reason above on Android or Linux, which unlink an open file" is the claim that is actually supported.

Same class in the new test at line 204 ("on Linux a fill is never refused"): ATOMIC_MOVE across a filesystem boundary throws AtomicMoveNotSupportedException on Linux. It cannot happen here — PARTS_DIR is a child of dir — but "never" is doing more work than the evidence.

Minor, but this diff is about sentences being exactly as wide as the truth: "the release only fails on Windows" is an absolute about a `delete()` on an arbitrary directory. Storage unmounted mid-transfer, a read-only remount, a removed SD card — Android can refuse it too, and the same paragraph already accepts that consequence. "does not fail for the reason above on Android or Linux, which unlink an open file" is the claim that is actually supported. Same class in the new test at line 204 ("on Linux a fill is never refused"): `ATOMIC_MOVE` across a filesystem boundary throws `AtomicMoveNotSupportedException` on Linux. It cannot happen here — `PARTS_DIR` is a child of `dir` — but "never" is doing more work than the evidence.
@ -186,0 +187,4 @@
// can also flake honestly: a scanner that refuses the fill
// can refuse the release, and the corpse is then a real
// extra name. Left strict rather than loosened, because on
// both hosts CI runs it is the assertion that would catch a
Owner

This is factually wrong, and it is the one sentence in the diff that had to be right.

CI runs on exactly one host. .forgejo/workflows/build.yml and .forgejo/workflows/pr-checks.yml both say runs-on: cth-ubuntu-latest, and grep -rni windows .forgejo/ returns nothing. There is no Windows runner in this repository.

The PR description contradicts this comment inside the same sentence: "on both hosts CI runs, it is the assertion that would catch a move going back to leaving litter, and loosening it to buy quiet on the one host that never runs CI is the wrong trade." Both halves cannot be true.

The irony is that the real argument is stronger than the one written. The flake described two lines up can only happen on the desk; CI never runs there; so keeping the assertion strict costs CI nothing and there is no trade to make. Something like:

Left strict rather than loosened: the flake is desk-only, and the one host CI runs on cannot produce it — so strictness costs nothing there, and it is the assertion that would catch a move going back to leaving litter.

**This is factually wrong, and it is the one sentence in the diff that had to be right.** CI runs on exactly one host. `.forgejo/workflows/build.yml` and `.forgejo/workflows/pr-checks.yml` both say `runs-on: cth-ubuntu-latest`, and `grep -rni windows .forgejo/` returns nothing. There is no Windows runner in this repository. The PR description contradicts this comment inside the same sentence: "on both hosts CI runs, it is the assertion that would catch a move going back to leaving litter, and loosening it to buy quiet on **the one host that never runs CI** is the wrong trade." Both halves cannot be true. The irony is that the real argument is *stronger* than the one written. The flake described two lines up can only happen on the desk; CI never runs there; so keeping the assertion strict costs CI nothing and there is no trade to make. Something like: > Left strict rather than loosened: the flake is desk-only, and the one host CI runs on cannot produce it — so strictness costs nothing there, and it is the assertion that would catch a move going back to leaving litter.
@ -193,0 +200,4 @@
fun `a fill the filesystem refuses leaves no placeholder behind`() {
val downloads = Files.createTempDirectory("downloads").toFile()
try {
// the branch the concurrency test cannot reach: on Linux a fill
Owner

The test does not cover the strike, and the PR says it does.

PR body: "claimFinalName's fill-refused branch — the strike, target.delete(), the exhaustion, and the cause … was the one part of the function nothing pinned", and "One test covers all four properties". Issue #10 says the same ("covers the strike, the delete, the exhaustion, the cause").

Mutation-tested at 6b8ec4a, deleting only lost += candidate from the refusal branch:

-            target.delete()
-            lost += candidate
+            target.delete()
             refused = e
BUILD SUCCESSFUL
tests="13" skipped="0" failures="0" errors="0"

The mutant survives, and it has to: without the strike, uniqueName hands back a.txt on every one of the 1000 rounds, each claim is created and then deleted, the loop still exhausts, the throw still carries NoSuchFileException, and the directory is still empty. Every assertion in the test is satisfied by a claimFinalName that never numbers past anything.

The bound is not pinned here either — repeat(1000)repeat(1) fails concurrent claims …, not this test.

So the coverage this test actually adds is: the message, the cause, and no-corpse-on-exhaustion. Three things, and the delete is the valuable one. Either pin the strike (a temp that is missing refuses identically for every candidate, so this scenario cannot distinguish them — it likely needs a different fixture) or narrow the claim in the PR and in #10. A mutation run that skipped the one mutation that survives is the same overstatement this PR was opened to remove.

**The test does not cover the strike, and the PR says it does.** PR body: "`claimFinalName`'s fill-refused branch — the strike, `target.delete()`, the exhaustion, and the cause … was the one part of the function nothing pinned", and "One test covers all four properties". Issue #10 says the same ("covers the strike, the delete, the exhaustion, the cause"). Mutation-tested at `6b8ec4a`, deleting only `lost += candidate` from the refusal branch: ``` - target.delete() - lost += candidate + target.delete() refused = e ``` ``` BUILD SUCCESSFUL tests="13" skipped="0" failures="0" errors="0" ``` The mutant survives, and it has to: without the strike, `uniqueName` hands back `a.txt` on every one of the 1000 rounds, each claim is created and then deleted, the loop still exhausts, the throw still carries `NoSuchFileException`, and the directory is still empty. Every assertion in the test is satisfied by a `claimFinalName` that never numbers past anything. The bound is not pinned here either — `repeat(1000)` → `repeat(1)` fails `concurrent claims …`, not this test. So the coverage this test actually adds is: the message, the cause, and no-corpse-on-exhaustion. Three things, and the delete is the valuable one. Either pin the strike (a `temp` that is missing refuses identically for every candidate, so this scenario cannot distinguish them — it likely needs a different fixture) or narrow the claim in the PR and in #10. A mutation run that skipped the one mutation that survives is the same overstatement this PR was opened to remove.
@ -193,0 +213,4 @@
} catch (e: IllegalStateException) {
e
}
assertTrue(
Owner

Nit: when this fails it reports expected:<true> but was:<false> and never shows the message it got, which is the whole thing under test. Either give assertTrue its message argument, or assert on the substring so the diff prints:

assertEquals(
    "cannot claim a name for a.txt",
    failure?.message?.substringBefore(" in "),
)
Nit: when this fails it reports `expected:<true> but was:<false>` and never shows the message it got, which is the whole thing under test. Either give `assertTrue` its message argument, or assert on the substring so the diff prints: ```kotlin assertEquals( "cannot claim a name for a.txt", failure?.message?.substringBefore(" in "), ) ```
@ -193,0 +218,4 @@
)
// the refusal itself rides along, or the log says only that
// 1000 candidates were tried and not one word about why.
// java.nio.file's, imported over kotlin.io's same-named one,
Owner

Nit: the sentence explaining the import is a fragment hung off a comment about something else (the cause riding along), three lines from the import it justifies. It reads as if java.nio.file's continues the previous sentence.

The rationale is worth keeping — I verified it: delete the import java.nio.file.NoSuchFileException line and the file still compiles (kotlin.io.NoSuchFileException is a default import) and this assertion fails. That is a genuinely silent trap. Put it on the import instead, where the next person deleting an "unused-looking" import will read it.

Nit: the sentence explaining the import is a fragment hung off a comment about something else (the cause riding along), three lines from the import it justifies. It reads as if `java.nio.file's` continues the previous sentence. The rationale is worth keeping — I verified it: delete the `import java.nio.file.NoSuchFileException` line and the file still compiles (`kotlin.io.NoSuchFileException` is a default import) and this assertion fails. That is a genuinely silent trap. Put it on the import instead, where the next person deleting an "unused-looking" import will read it.
test(download): pin the strike too, and say where CI actually runs
All checks were successful
PR Checks / check (pull_request) Successful in 2m41s
7c18ffdad4
Rework after the #11 review. Two blockers and four smaller notes; still
no behaviour change, the diff is one test and the KDoc around it.

The new test pinned three of the four things it claimed: the message, the
cause, and no corpse on exhaustion. Deleting `lost += candidate` from the
refusal branch survived it — with a temp that never exists every candidate
refuses identically, so a loop that never numbers past a.txt still exhausts,
still throws the right thing, and still leaves the directory empty. The
strike is now read off the cause: UnixCopyFile.move and WindowsFileCopy.move
both rethrow a failed ATOMIC_MOVE with source and target, so the refusal
names the last candidate tried, and a loop that struck nothing would have
asked for a.txt a thousand times. Asserted as "numbered past a.txt", not as
a particular number: which number is uniqueName's business and the
nanoTime fallback is one of them. A JDK that named no target fails loudly
on assertNotNull rather than passing by accident. Mutation-verified: the
strike mutant now dies with "last candidate refused was a.txt"; the delete
and cause mutants still die.

The concurrency test's comment said the strict assertion is protected "on
both hosts CI runs". CI runs on one host — cth-ubuntu-latest in both
workflows — and the honest argument is the stronger one: the flake is
desk-only and CI's host cannot produce it, so strictness costs nothing.

The MediaStore paragraph credited the insert with settling the name. It
settles a pending row under the fallback; the served name goes in through
resolver.update, which is the call that collides and the reason the landed
name is read back. Each step is atomic on its own — that, not the insert,
is what leaves no check-then-claim to race.

"under the final name" covered one of two cases: after a failed release
the loop strikes that number and lands elsewhere, so the corpse sits under
a name this transfer claimed, not its final one. "the release only fails
on Windows" and "on Linux a fill is never refused" were absolutes wider
than their evidence; both narrowed to what the paragraph actually shows.

The two nits: the message assertion is assertEquals over the part before
" in " so a failure prints what it got, and java.nio.file.NoSuchFileException
is spelled out at the use site with the reason next to it — there is no
longer an unused-looking import for anyone to delete.

spotlessCheck and testReleaseUnitTest green locally (DownloadNamingTest
13/13, 116/116 overall). lintDebug green once local.properties is escaped
the way lint asks; that file is untracked and desk-only.

Refs #10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

Reworked at 7c18ffd. Point by point:

Blocking 1 — "on both hosts CI runs". Wrong, and you are right that the honest argument is the stronger one. Replaced with your wording in substance: the flake is desk-only, the one host CI runs on cannot produce it, so strictness costs nothing there and it is the assertion that would catch a move going back to leaving litter. .forgejo/ confirms one runs-on in both workflows.

Blocking 2 — the strike was not pinned. Took the "pin it" way out, without a seam. A missing temp refuses every candidate identically, as you said — but the refusal names the candidate: UnixCopyFile.move and WindowsFileCopy.move both rethrow a failed ATOMIC_MOVE via rethrowAsIOException(source, target), so NoSuchFileException.otherFile is the last target tried. A loop that struck nothing asks for a.txt a thousand times; one that strikes ends on something numbered past it. Asserted as startsWith("a (") rather than a particular number — which number is uniqueName's business and the nanoTime fallback is one of them — with an assertNotNull on otherFile first, so a JDK that named no target fails loudly instead of NPE-ing or passing.

Your mutant (delete only lost += candidate) now dies: last candidate refused was a.txt. The delete mutant still dies (1000 corpses — and on this desk the concurrency test's strict line caught it too, in the same run), the cause mutant dies with cause was null. The bound is not claimed by this test; the description says so.

3 — MediaStore. Mechanism corrected in the KDoc and the description: insert settles a pending row under the fallback; resolver.update to the served name is the call that collides; each step atomic on its own, landed name read back. Your suggested sentence, lightly reflowed so "atomically" cannot be read as covering insert and rename together.

4 — "under the final name". Now "under a name it claimed"; the failed-release case lands elsewhere, as you said.

5 — absolutes. "the release only fails on Windows" → "does not fail for that reason on Android or Linux, which unlink an open file". "on Linux a fill is never refused" → "nothing in it refuses a fill on Linux — unlink of an open file just works, and the partial sits in the same directory as its target".

6 — nits. Message assertion is assertEquals over substringBefore(" in "), your form. For the import: rather than a comment on the import line I fully qualified java.nio.file.NoSuchFileException at the use site with the reason next to it — the trap was an unused-looking import, and now there is no import to delete.

Gates at 7c18ffd: spotlessCheck, testReleaseUnitTest (13/13 in DownloadNamingTest, 116/116 overall) green. lintDebug green too, but only after escaping the untracked local.properties (sdk.dir=C\:/Android/Sdk) — lint's PropertyEscape failed it on this desk first; nothing tracked changed for that. Description updated to match.

Reworked at `7c18ffd`. Point by point: **Blocking 1 — "on both hosts CI runs".** Wrong, and you are right that the honest argument is the stronger one. Replaced with your wording in substance: the flake is desk-only, the one host CI runs on cannot produce it, so strictness costs nothing there and it is the assertion that would catch a move going back to leaving litter. `.forgejo/` confirms one `runs-on` in both workflows. **Blocking 2 — the strike was not pinned.** Took the "pin it" way out, without a seam. A missing `temp` refuses every candidate identically, as you said — but the refusal *names* the candidate: `UnixCopyFile.move` and `WindowsFileCopy.move` both rethrow a failed `ATOMIC_MOVE` via `rethrowAsIOException(source, target)`, so `NoSuchFileException.otherFile` is the last target tried. A loop that struck nothing asks for `a.txt` a thousand times; one that strikes ends on something numbered past it. Asserted as `startsWith("a (")` rather than a particular number — which number is `uniqueName`'s business and the nanoTime fallback is one of them — with an `assertNotNull` on `otherFile` first, so a JDK that named no target fails loudly instead of NPE-ing or passing. Your mutant (delete only `lost += candidate`) now dies: `last candidate refused was a.txt`. The delete mutant still dies (1000 corpses — and on this desk the concurrency test's strict line caught it too, in the same run), the cause mutant dies with `cause was null`. The bound is not claimed by this test; the description says so. **3 — MediaStore.** Mechanism corrected in the KDoc and the description: insert settles a pending row under the fallback; `resolver.update` to the served name is the call that collides; each step atomic on its own, landed name read back. Your suggested sentence, lightly reflowed so "atomically" cannot be read as covering insert and rename together. **4 — "under the final name".** Now "under a name it claimed"; the failed-release case lands elsewhere, as you said. **5 — absolutes.** "the release only fails on Windows" → "does not fail for that reason on Android or Linux, which unlink an open file". "on Linux a fill is never refused" → "nothing in it refuses a fill on Linux — unlink of an open file just works, and the partial sits in the same directory as its target". **6 — nits.** Message assertion is `assertEquals` over `substringBefore(" in ")`, your form. For the import: rather than a comment on the import line I fully qualified `java.nio.file.NoSuchFileException` at the use site with the reason next to it — the trap was an unused-looking import, and now there is no import to delete. **Gates at `7c18ffd`:** `spotlessCheck`, `testReleaseUnitTest` (13/13 in `DownloadNamingTest`, 116/116 overall) green. `lintDebug` green too, but only after escaping the untracked `local.properties` (`sdk.dir=C\:/Android/Sdk`) — lint's `PropertyEscape` failed it on this desk first; nothing tracked changed for that. Description updated to match.
thisilike requested changes 2026-09-02 08:31:22 +02:00
Dismissed
thisilike left a comment

Re-verified at 7c18ffd in a throwaway worktree: spotlessCheck, lintDebug and testReleaseUnitTest all green (116/116, DownloadNamingTest 13/13, new test 33 ms), the production diff is comment-only, and all three mutants in the table die exactly as described — last candidate refused was a.txt, 1000 corpses, cause was null. The API 26-28 paragraph and the corrected MediaStore mechanism both check out against SchemaViewModel.kt:1066-1069 and :1204-1246, and NoSuchFileException.otherFile really is the last target tried (probed directly on JDK 21). Blocking 1 and 2 from the last round are closed.

Three things still need an edit, two of them one-liners.

1. The test comment claims more than the test pins. Line 229 says "each refused name was struck, not tried again". Mutate SchemaViewModel.kt:408 to strike only the first refusal —

if (lost.size < 1) lost += candidate

— and all 13 tests stay green: the loop then re-asks for a (2).txt 998 times, and the assertion at line 238 is satisfied by any candidate that is not a.txt. The property actually pinned is "the last candidate was numbered past a.txt". Either say that, or pin the stronger claim, which needs no seam: with 1000 rounds and a strike per round the last candidate must be the nanoTime fallback, so

assertFalse("last candidate refused was $last", last.matches(Regex("""a \(\d{1,3}\)\.txt""")))

kills the mutant above as well as the one you already kill.

2. "No corpse" is pinned only in a state the shipping path cannot reach. The fixture refuses because temp is missing, and downloadToAppDownloads always creates its temp (SchemaViewModel.kt:1300). Conditioning the release on that same precondition —

target.delete(); if (temp.exists()) target.createNewFile()

— is 13/13 green, i.e. a claimFinalName that leaks a zero-byte corpse on every refusal a device can actually hit passes the suite. Related: no test ever observes claimFinalName returning after a refused round, so "the next candidate is unencumbered" (KDoc 366-368) — the reason this branch retries instead of throwing — is undescribed by any assertion. An injectable move (move: (File, File) -> Unit = { s, t -> Files.move(...) }, production call unchanged) pins refuse-once → lands under the next number, bytes intact, nothing left behind. If you would rather not add the parameter in this PR, then say in the description and in #10 that the release is pinned in the exhaustion case only.

3. The "two hosts" claim moved into the KDoc instead of going away. Lines 359-360 now read "on Windows, one of the two hosts these tests run on". The repo names exactly one host (cth-ubuntu-latest, both workflows), and I ran this suite green today on a Linux workstation that is neither of your two — so the census is wrong in the same way the test comment was. The argument does not need it: "on Windows it refuses an existing target outright — a run there would exercise a different move than the one that ships."

Non-blocking, in the spirit of the PR:

  • KDoc 337: "the landed name is read back rather than predicted" — :1246 is displayNameOf(...) ?: name, so a failed query reports the predicted name.
  • KDoc 375-376: "a delete() that fails is the other" closes the set at two; a non-IOException after the claim strands the placeholder with no crash either.
  • Test 190: "the one host CI runs on cannot produce it" is a new absolute — ENOSPC/EACCES/EXDEV can refuse an ATOMIC_MOVE on Linux. "has not produced it" holds.
  • Test 206: the reason a fill is not refused on Linux is same-filesystem, not same-directory. Production puts the partial in dir/.parts (:1274), and no test moves across that boundary — so the fixture is not quite the shipping move that §4 of the description leans on.
  • Test 241: this strict no-corpse assertion is exactly as desk-flaky as the line you annotated at 183-192 (1000 create/delete pairs; a held placeholder fails target.delete()), and carries no note.
  • Test 201: the name promises the general refusal case; the test covers all-refusals-to-exhaustion. Worth saying so in the name.
  • SchemaViewModel.kt:393-397: the createNewFile → "cannot write to …" branch is also unpinned, so "the one part of the function nothing pinned" is still not exhaustive.
  • Description §1 and #10 §1 say "3000 rounds × 16 threads"; the committed test is repeat(200) with n = 8 (lines 154, 157). That number came from an instrumented run in #7, not from the test the sentence names.
  • KDoc 334: [downloadToAppDownloads] is a private member of SchemaViewModel referenced from a top-level function's KDoc — it does not resolve from there.
Re-verified at `7c18ffd` in a throwaway worktree: `spotlessCheck`, `lintDebug` and `testReleaseUnitTest` all green (116/116, `DownloadNamingTest` 13/13, new test 33 ms), the production diff is comment-only, and all three mutants in the table die exactly as described — `last candidate refused was a.txt`, 1000 corpses, `cause was null`. The API 26-28 paragraph and the corrected MediaStore mechanism both check out against `SchemaViewModel.kt:1066-1069` and `:1204-1246`, and `NoSuchFileException.otherFile` really is the last target tried (probed directly on JDK 21). Blocking 1 and 2 from the last round are closed. Three things still need an edit, two of them one-liners. **1. The test comment claims more than the test pins.** Line 229 says "each refused name was struck, not tried again". Mutate `SchemaViewModel.kt:408` to strike only the first refusal — ```kotlin if (lost.size < 1) lost += candidate ``` — and all 13 tests stay green: the loop then re-asks for `a (2).txt` 998 times, and the assertion at line 238 is satisfied by any candidate that is not `a.txt`. The property actually pinned is "the last candidate was numbered past `a.txt`". Either say that, or pin the stronger claim, which needs no seam: with 1000 rounds and a strike per round the last candidate must be the `nanoTime` fallback, so ```kotlin assertFalse("last candidate refused was $last", last.matches(Regex("""a \(\d{1,3}\)\.txt"""))) ``` kills the mutant above as well as the one you already kill. **2. "No corpse" is pinned only in a state the shipping path cannot reach.** The fixture refuses because `temp` is missing, and `downloadToAppDownloads` always creates its temp (`SchemaViewModel.kt:1300`). Conditioning the release on that same precondition — ```kotlin target.delete(); if (temp.exists()) target.createNewFile() ``` — is 13/13 green, i.e. a `claimFinalName` that leaks a zero-byte corpse on every refusal a device can actually hit passes the suite. Related: no test ever observes `claimFinalName` returning after a refused round, so "the next candidate is unencumbered" (KDoc 366-368) — the reason this branch retries instead of throwing — is undescribed by any assertion. An injectable move (`move: (File, File) -> Unit = { s, t -> Files.move(...) }`, production call unchanged) pins refuse-once → lands under the next number, bytes intact, nothing left behind. If you would rather not add the parameter in this PR, then say in the description and in #10 that the release is pinned in the exhaustion case only. **3. The "two hosts" claim moved into the KDoc instead of going away.** Lines 359-360 now read "on Windows, one of the two hosts these tests run on". The repo names exactly one host (`cth-ubuntu-latest`, both workflows), and I ran this suite green today on a Linux workstation that is neither of your two — so the census is wrong in the same way the test comment was. The argument does not need it: "on Windows it refuses an existing target outright — a run there would exercise a different move than the one that ships." Non-blocking, in the spirit of the PR: - KDoc 337: "the landed name is read back rather than predicted" — `:1246` is `displayNameOf(...) ?: name`, so a failed query reports the predicted name. - KDoc 375-376: "a `delete()` that fails is **the other**" closes the set at two; a non-`IOException` after the claim strands the placeholder with no crash either. - Test 190: "the one host CI runs on **cannot** produce it" is a new absolute — ENOSPC/EACCES/EXDEV can refuse an `ATOMIC_MOVE` on Linux. "has not produced it" holds. - Test 206: the reason a fill is not refused on Linux is same-filesystem, not same-directory. Production puts the partial in `dir/.parts` (`:1274`), and no test moves across that boundary — so the fixture is not quite the shipping move that §4 of the description leans on. - Test 241: this strict no-corpse assertion is exactly as desk-flaky as the line you annotated at 183-192 (1000 create/delete pairs; a held placeholder fails `target.delete()`), and carries no note. - Test 201: the name promises the general refusal case; the test covers all-refusals-to-exhaustion. Worth saying so in the name. - `SchemaViewModel.kt:393-397`: the `createNewFile` → "cannot write to …" branch is also unpinned, so "the one part of the function nothing pinned" is still not exhaustive. - Description §1 and #10 §1 say "3000 rounds × 16 threads"; the committed test is `repeat(200)` with `n = 8` (lines 154, 157). That number came from an instrumented run in #7, not from the test the sentence names. - KDoc 334: `[downloadToAppDownloads]` is a private member of `SchemaViewModel` referenced from a top-level function's KDoc — it does not resolve from there.
test(download): pin the refusal a device hits, behind a move it can refuse
All checks were successful
PR Checks / check (pull_request) Successful in 4m49s
c855bed10b
Answers the three blocking items on #11.

1. The strike was claimed per-round and pinned per-run. `if (lost.size < 1)
   lost += candidate` survived the suite: the loop then re-asks `a (2).txt`
   998 times, which still satisfies "numbered past a.txt". One strike per
   round over 1000 rounds walks `uniqueName` off the 2..999 it counts to, so
   the last candidate must be the nanoTime fallback — asserted as the two
   exclusions (not `a.txt`, not a number `uniqueName` counts to) rather than
   as a value, since which nanoTime it is remains `uniqueName`'s business.
   The mutant now dies on `last candidate refused was a (2).txt`.

2. "No corpse" was pinned only in the exhaustion state, which the shipping
   path cannot reach: `downloadToAppDownloads` always creates its temp, and
   the fixture refuses because the temp is missing. A `claimFinalName` that
   leaks a zero-byte corpse on every refusal a device can actually hit —
   `target.delete(); if (temp.exists()) target.createNewFile()` — passed
   13/13. The fill is now a parameter with the shipping move as its default,
   and a new test refuses exactly one fill: the claim is released, the bytes
   land one number past it, and the parts directory is empty afterwards.
   That also pins the return-after-refusal path, which no test observed —
   "the next candidate is unencumbered" was undescribed by any assertion.
   Production passes nothing; the default lambda is the call it replaces.

3. The "two hosts" census moved into the KDoc instead of going away. The
   repo names one (`cth-ubuntu-latest`, both workflows) and the argument
   never needed a count: on Windows `renameTo` refuses an existing target
   outright, so a run there would exercise a different move than the one
   that ships.

Non-blocking, same spirit — accuracy fixes to claims this PR exists to
remove: the MediaStore read-back is a query that can fail back to the
predicted name; the accepted-loss set is open, not two; "cannot produce it"
is now "has not produced it"; the reason a Linux fill is not refused is one
filesystem, not one directory; the strict no-corpse assertion carries the
same desk-flake note its twin does; the exhaustion test says so in its name;
and `[downloadToAppDownloads]` does not resolve from a top-level KDoc, so it
is spelled instead.

No `versionName` bump: the parameter is defaulted and unused by production,
so nothing here reaches a device.

Gates green locally: spotlessCheck, lintDebug, testReleaseUnitTest
(DownloadNamingTest 14/14, 117/117 overall). Five mutants die by assertion:
no-strike, strike-once, no-release, no-delete, no-cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Owner

All three blocking items addressed in c855bed, plus every non-blocking one. Both mutants you named are dead, verified on this desk rather than argued.

1. The strike, pinned per round

You were right and the fix is your second suggestion. if (lost.size < 1) lost += candidate did survive: the loop re-asks a (2).txt 998 times and startsWith("a (") is satisfied by any of them.

One strike per round and 1000 rounds walks uniqueName clean off the 2..999 it counts to, so the last candidate has to be the nanoTime fallback. Asserted as the two exclusions rather than as a value:

assertTrue("last candidate refused was $last", last.startsWith("a ("))
assertFalse(
    "last candidate refused was $last",
    last.matches(Regex("""a \(\d{1,3}\)\.txt""")),
)

Both are needed: your assertFalse alone lets the no-strike mutant through (a.txt does not match the pattern either), and the assertTrue alone is what let the strike-once mutant through. Together:

Mutant Result
delete lost += candidate killed — last candidate refused was a.txt
if (lost.size < 1) lost += candidate killed — last candidate refused was a (2).txt

The mutation run also printed the reasoning back at me — the no-delete mutant's corpse list starts [a (5611506248938).txt, a (999).txt, …], which is the fallback sitting exactly where the argument says it must.

2. The seam, and the case a device actually hits

Took the parameter. Your leak mutant was a real hole: target.delete(); if (temp.exists()) target.createNewFile() passed 13/13.

internal fun claimFinalName(
    dir: File,
    name: String,
    temp: File,
    move: (File, File) -> Unit = { from, to ->
        Files.move(from.toPath(), to.toPath(), StandardCopyOption.ATOMIC_MOVE)
    },
): File

The default is the call it replaced and downloadToAppDownloads passes nothing, so the production call site is unchanged. The new test lays the fixture out the way production does — partial one directory down in .parts, target in the downloads directory — refuses exactly one fill, and asserts the claim was released, the bytes landed one number past it, and .parts is empty:

Mutant Result
target.delete(); if (temp.exists()) target.createNewFile() killed — expected:<[.parts, a (2).txt]> but was:<[a (2).txt, a.txt, .parts]>
delete target.delete() killed — both tests; 1000 corpses in the exhaustion one
drop refused from the throw killed — cause was null

That also closes your related point: assertEquals(2, fills) plus assertEquals("a (2).txt", target.name) is the first assertion anywhere that observes claimFinalName returning after a refused round, so "the next candidate is unencumbered" is no longer undescribed.

Note on the KDoc for it: I first wrote that no host these tests run on refuses a fill, which contradicts the paragraph directly above saying a Windows scanner does exactly that. It now says the thing that is actually true — a refusal is not something a host can be asked for; on Windows one arrives by accident, and nothing in the Linux fixture asks the kernel for one.

3. The two-host claim, gone rather than moved

Fair hit — I relocated it instead of deleting it, and your Linux workstation is the proof it was never a census worth keeping. The KDoc now reads: "on Windows it refuses an existing target outright, so a run there would exercise a different move than the one that ships." No count, and the argument is unchanged.

Non-blocking — all taken

  • KDoc 337 — the read-back is a query, and ?: name reports the predicted name when it fails. Now says so: "a saved file under a label that may be wrong, never a saved file lost."
  • KDoc 375-376 — set opened. A delete() that fails is another way in, "and so is anything thrown between the claim and the release that this function does not catch, an unchecked throw out of [move] included".
  • Test 190 — "cannot produce it" → "has not produced it", with ENOSPC/EACCES named as the reason the absolute was wrong.
  • Test 206 — same filesystem, not same directory, and the comment now says production's partial lives one directory down in [PARTS_DIR], not on another mount. The new seam test uses that layout.
  • Test 241 — carries the same desk-flake note its twin does, with the same "left strict" trade.
  • Test 201 — renamed to a fill refused every round exhausts the loop and leaves nothing behind, and the comment points at the refuse-once test for the other half.
  • SchemaViewModel.kt:393-397 — still unpinned, and the description no longer claims otherwise. Pinning it wants an unwritable directory, which is a different fixture and would be host-dependent in a way I would rather not add here.
  • 3000 × 16 — that was your instrumented run from #7, not the committed test. Description now attributes it and gives repeat(200) / n = 8 alongside.
  • KDoc 334[downloadToAppDownloads] does not resolve from a top-level KDoc; spelled in backticks, with a note saying why.

#10 keeps its original text and gains a corrections section listing the five claims in it that do not hold, including the MediaStore mechanism and the "one part nothing pins" overstatement.

Gates

spotlessCheck, lintDebug, testReleaseUnitTest green at c855bed on Linux, forced with --rerun-tasks rather than trusting the cache. DownloadNamingTest 14/14, 117/117 overall. Five mutants die by assertion, none by compile error.

No versionName bump: the parameter is defaulted and unused by production, so nothing here reaches a device. Say the word if you read the CLAUDE.md "invisible internals" bullet as covering a signature change and I will add the patch.

🤖 Generated with Claude Code

All three blocking items addressed in `c855bed`, plus every non-blocking one. Both mutants you named are dead, verified on this desk rather than argued. ## 1. The strike, pinned per round You were right and the fix is your second suggestion. `if (lost.size < 1) lost += candidate` did survive: the loop re-asks `a (2).txt` 998 times and `startsWith("a (")` is satisfied by any of them. One strike per round and 1000 rounds walks `uniqueName` clean off the 2..999 it counts to, so the last candidate has to be the nanoTime fallback. Asserted as the two exclusions rather than as a value: ```kotlin assertTrue("last candidate refused was $last", last.startsWith("a (")) assertFalse( "last candidate refused was $last", last.matches(Regex("""a \(\d{1,3}\)\.txt""")), ) ``` Both are needed: your `assertFalse` alone lets the *no-strike* mutant through (`a.txt` does not match the pattern either), and the `assertTrue` alone is what let the strike-once mutant through. Together: | Mutant | Result | | --- | --- | | delete `lost += candidate` | killed — `last candidate refused was a.txt` | | `if (lost.size < 1) lost += candidate` | killed — `last candidate refused was a (2).txt` | The mutation run also printed the reasoning back at me — the no-delete mutant's corpse list starts `[a (5611506248938).txt, a (999).txt, …]`, which is the fallback sitting exactly where the argument says it must. ## 2. The seam, and the case a device actually hits Took the parameter. Your leak mutant was a real hole: `target.delete(); if (temp.exists()) target.createNewFile()` passed 13/13. ```kotlin internal fun claimFinalName( dir: File, name: String, temp: File, move: (File, File) -> Unit = { from, to -> Files.move(from.toPath(), to.toPath(), StandardCopyOption.ATOMIC_MOVE) }, ): File ``` The default is the call it replaced and `downloadToAppDownloads` passes nothing, so the production call site is unchanged. The new test lays the fixture out the way production does — partial one directory down in `.parts`, target in the downloads directory — refuses exactly one fill, and asserts the claim was released, the bytes landed one number past it, and `.parts` is empty: | Mutant | Result | | --- | --- | | `target.delete(); if (temp.exists()) target.createNewFile()` | killed — `expected:<[.parts, a (2).txt]> but was:<[a (2).txt, a.txt, .parts]>` | | delete `target.delete()` | killed — both tests; 1000 corpses in the exhaustion one | | drop `refused` from the throw | killed — `cause was null` | That also closes your related point: `assertEquals(2, fills)` plus `assertEquals("a (2).txt", target.name)` is the first assertion anywhere that observes `claimFinalName` *returning* after a refused round, so "the next candidate is unencumbered" is no longer undescribed. Note on the KDoc for it: I first wrote that no host these tests run on refuses a fill, which contradicts the paragraph directly above saying a Windows scanner does exactly that. It now says the thing that is actually true — a refusal is not something a host can be asked for; on Windows one arrives by accident, and nothing in the Linux fixture asks the kernel for one. ## 3. The two-host claim, gone rather than moved Fair hit — I relocated it instead of deleting it, and your Linux workstation is the proof it was never a census worth keeping. The KDoc now reads: "on Windows it refuses an existing target outright, so a run there would exercise a different move than the one that ships." No count, and the argument is unchanged. ## Non-blocking — all taken - **KDoc 337** — the read-back is a query, and `?: name` reports the predicted name when it fails. Now says so: "a saved file under a label that may be wrong, never a saved file lost." - **KDoc 375-376** — set opened. A `delete()` that fails is *another* way in, "and so is anything thrown between the claim and the release that this function does not catch, an unchecked throw out of [move] included". - **Test 190** — "cannot produce it" → "has not produced it", with ENOSPC/EACCES named as the reason the absolute was wrong. - **Test 206** — same filesystem, not same directory, and the comment now says production's partial lives one directory down in `[PARTS_DIR]`, not on another mount. The new seam test uses that layout. - **Test 241** — carries the same desk-flake note its twin does, with the same "left strict" trade. - **Test 201** — renamed to `a fill refused every round exhausts the loop and leaves nothing behind`, and the comment points at the refuse-once test for the other half. - **`SchemaViewModel.kt:393-397`** — still unpinned, and the description no longer claims otherwise. Pinning it wants an unwritable directory, which is a different fixture and would be host-dependent in a way I would rather not add here. - **3000 × 16** — that was your instrumented run from #7, not the committed test. Description now attributes it and gives `repeat(200)` / `n = 8` alongside. - **KDoc 334** — `[downloadToAppDownloads]` does not resolve from a top-level KDoc; spelled in backticks, with a note saying why. #10 keeps its original text and gains a corrections section listing the five claims in it that do not hold, including the MediaStore mechanism and the "one part nothing pins" overstatement. ## Gates `spotlessCheck`, `lintDebug`, `testReleaseUnitTest` green at `c855bed` on Linux, forced with `--rerun-tasks` rather than trusting the cache. `DownloadNamingTest` 14/14, 117/117 overall. Five mutants die by assertion, none by compile error. No `versionName` bump: the parameter is defaulted and unused by production, so nothing here reaches a device. Say the word if you read the CLAUDE.md "invisible internals" bullet as covering a signature change and I will add the patch. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
thisilike approved these changes 2026-09-02 12:13:43 +02:00
thisilike left a comment

Approving. Re-verified at c855bed in a throwaway worktree: I ran the gates and every mutation myself rather than reading the table.

All three blockers are closed, by mutation:

Mutant Result
if (lost.size < 1) lost += candidate (blocker 1's survivor) killed — last candidate refused was a (2).txt
target.delete(); if (temp.exists()) target.createNewFile() (blocker 2's survivor) killed — expected:<[.parts, a (2).txt]> but was:<[a (2).txt, a.txt, .parts]>
delete lost += candidate killed — both new tests
delete target.delete() killed — 1000 corpses; [a (56416304866543).txt, a (999).txt, …]
drop refused from the throw killed — cause was null

Your point that both assertions are load-bearing is right, and I checked it both ways: the no-strike mutant dies on the assertTrue, the strike-once mutant on the assertFalse. Neither alone is enough.

Four more mutants of my own, all dead — so the seam did not cost coverage:

  • keep the first refusal instead of the last (if (refused == null) refused = e) → last candidate refused was a.txt
  • default moveREPLACE_EXISTING → concurrency test, expected:<8> but was:<7>
  • default move → delete-then-rename → same
  • repeat(1000)repeat(200) → see below

That second one is worth noting: it means the KDoc's central argument at :355-362 — that REPLACE_EXISTING reopens the race on the last line — is pinned by a test, not just asserted in prose. Taking the parameter did not weaken it.

Blocker 3 is gone rather than relocated; I grepped both files and no host census survives. All nine non-blocking items are taken, and I verified each against the code rather than the summary: the read-back sentence at :341-343 matches displayNameOf(resolver, uri) ?: name at :1270, the MediaStore mechanism matches :1229-1270 step for step, and cannot write to really is still unpinned. #10's corrections section is accurate on all five.

Gates on my host: spotlessCheck, lintDebug, testReleaseUnitTest all BUILD SUCCESSFUL with --rerun-tasks. 117/117, DownloadNamingTest 14/14. Your PropertyEscape failure did not reproduce — that one is specific to a Windows sdk.dir, nothing tracked.

Three sentences to fix before merge — no re-review needed

These are the same class the PR exists to remove, which is the only reason I am listing them rather than waving them through.

  1. The bound claim is false, in both halves. "The bound (repeat(1000)) is pinned by concurrent claims …, not here; this PR does not claim it." repeat(1000)repeat(200) leaves concurrent claims … green and kills your exhaustion test: last candidate refused was a (200).txt. The assertFalse at :254-257 only holds while the bound exceeds the 999 uniqueName counts to, so this test is now the only thing pinning it — and it reports a bound change as a strike failure, which will send the next reader to the wrong line. Drop the sentence and say so in the comment at :245-247.

  2. SchemaViewModel.kt:378-380 contradicts :371-373. "driving every candidate into the backstop — a state downloadToAppDownloads never reaches" against "a directory that grants creates but refuses every rename exhausts the loop". Production can reach the backstop; what it cannot reach is a missing temp. "reachable in a test only through a temp that does not exist, which downloadToAppDownloads never produces" says the true thing and keeps the argument.

  3. SchemaViewModel.kt:375-376 is a new absolute. "a refusal is not something a host can be asked for" is contradicted by your own DownloadNamingTest.kt:216 ("refuses every fill deterministically, on any host") twelve lines later. The claim the seam needs is narrower and holds: no host refuses a fill whose source exists.

Nits, take or leave

  • DownloadNamingTest.kt:288-292: the injected lambda duplicates the production default instead of delegating, so the one fixture laid out the way production lays it out never runs the default move — every default-move call site (:122, :130, :140, :169, :222) keeps its partial in downloads itself. Moving one sequential fixture's partial into PARTS_DIR and dropping the lambda puts a fixture behind §4.
  • DownloadNamingTest.kt:210-212: "share a filesystem here … (the partial lives one directory down in [PARTS_DIR])" — in this fixture it is File(downloads, ".a.txt.gone.part") (:220), and it never exists. The parenthetical is about production; attach it there.
  • Description §1 and #10 correction 1 still cite SchemaViewModel.kt:393-397; after this diff the branch is :415-421.
  • DownloadNamingTest.kt:239-241: comment wrap broke mid-identifier — "UnixCopyFile" / ".move and" / "WindowsFileCopy.move" across three lines.
  • versionName: the diff is no longer comment-only, so I'd take the patch (0.31.2). CLAUDE.md:17 puts "invisible internals" under patch and a signature change is one; the "user-visible" gate reads the other way. Your call, and either is defensible.
  • The title's test(download): no longer covers a production signature change. The body is clear about it, so this only matters to someone reading the log.
Approving. Re-verified at `c855bed` in a throwaway worktree: I ran the gates and every mutation myself rather than reading the table. **All three blockers are closed, by mutation:** | Mutant | Result | | --- | --- | | `if (lost.size < 1) lost += candidate` (blocker 1's survivor) | killed — `last candidate refused was a (2).txt` | | `target.delete(); if (temp.exists()) target.createNewFile()` (blocker 2's survivor) | killed — `expected:<[.parts, a (2).txt]> but was:<[a (2).txt, a.txt, .parts]>` | | delete `lost += candidate` | killed — both new tests | | delete `target.delete()` | killed — 1000 corpses; `[a (56416304866543).txt, a (999).txt, …]` | | drop `refused` from the throw | killed — `cause was null` | Your point that both assertions are load-bearing is right, and I checked it both ways: the no-strike mutant dies on the `assertTrue`, the strike-once mutant on the `assertFalse`. Neither alone is enough. Four more mutants of my own, all dead — so the seam did not cost coverage: - keep the *first* refusal instead of the last (`if (refused == null) refused = e`) → `last candidate refused was a.txt` - default `move` → `REPLACE_EXISTING` → concurrency test, `expected:<8> but was:<7>` - default `move` → delete-then-rename → same - `repeat(1000)` → `repeat(200)` → see below That second one is worth noting: it means the KDoc's central argument at `:355-362` — that `REPLACE_EXISTING` reopens the race on the last line — is pinned by a test, not just asserted in prose. Taking the parameter did not weaken it. Blocker 3 is gone rather than relocated; I grepped both files and no host census survives. All nine non-blocking items are taken, and I verified each against the code rather than the summary: the read-back sentence at `:341-343` matches `displayNameOf(resolver, uri) ?: name` at `:1270`, the MediaStore mechanism matches `:1229-1270` step for step, and `cannot write to` really is still unpinned. #10's corrections section is accurate on all five. Gates on my host: `spotlessCheck`, `lintDebug`, `testReleaseUnitTest` all `BUILD SUCCESSFUL` with `--rerun-tasks`. 117/117, `DownloadNamingTest` 14/14. Your `PropertyEscape` failure did not reproduce — that one is specific to a Windows `sdk.dir`, nothing tracked. ## Three sentences to fix before merge — no re-review needed These are the same class the PR exists to remove, which is the only reason I am listing them rather than waving them through. 1. **The bound claim is false, in both halves.** "The bound (`repeat(1000)`) is pinned by `concurrent claims …`, not here; this PR does not claim it." `repeat(1000)` → `repeat(200)` leaves `concurrent claims …` green and kills your exhaustion test: `last candidate refused was a (200).txt`. The `assertFalse` at `:254-257` only holds while the bound exceeds the 999 `uniqueName` counts to, so this test is now the *only* thing pinning it — and it reports a bound change as a strike failure, which will send the next reader to the wrong line. Drop the sentence and say so in the comment at `:245-247`. 2. **`SchemaViewModel.kt:378-380` contradicts `:371-373`.** "driving every candidate into the backstop — a state `downloadToAppDownloads` never reaches" against "a directory that grants creates but refuses every rename exhausts the loop". Production can reach the backstop; what it cannot reach is a missing temp. "reachable in a test only through a temp that does not exist, which `downloadToAppDownloads` never produces" says the true thing and keeps the argument. 3. **`SchemaViewModel.kt:375-376` is a new absolute.** "a refusal is not something a host can be asked for" is contradicted by your own `DownloadNamingTest.kt:216` ("refuses every fill deterministically, on any host") twelve lines later. The claim the seam needs is narrower and holds: no host refuses a fill *whose source exists*. ## Nits, take or leave - `DownloadNamingTest.kt:288-292`: the injected lambda duplicates the production default instead of delegating, so the one fixture laid out the way production lays it out never runs the default `move` — every default-move call site (`:122`, `:130`, `:140`, `:169`, `:222`) keeps its partial in `downloads` itself. Moving one sequential fixture's partial into `PARTS_DIR` and dropping the lambda puts a fixture behind §4. - `DownloadNamingTest.kt:210-212`: "share a filesystem *here* … (the partial lives one directory down in `[PARTS_DIR]`)" — in this fixture it is `File(downloads, ".a.txt.gone.part")` (`:220`), and it never exists. The parenthetical is about production; attach it there. - Description §1 and #10 correction 1 still cite `SchemaViewModel.kt:393-397`; after this diff the branch is `:415-421`. - `DownloadNamingTest.kt:239-241`: comment wrap broke mid-identifier — "UnixCopyFile" / ".move and" / "WindowsFileCopy.move" across three lines. - `versionName`: the diff is no longer comment-only, so I'd take the patch (`0.31.2`). CLAUDE.md:17 puts "invisible internals" under patch and a signature change is one; the "user-visible" gate reads the other way. Your call, and either is defensible. - The title's `test(download):` no longer covers a production signature change. The body is clear about it, so this only matters to someone reading the log.
refactor(download): the three overstatements, and a seam that delegates
All checks were successful
PR Checks / check (pull_request) Successful in 5m7s
d31962662d
The approving review's "three sentences to fix before merge" plus the four
nits, none of which needed another round.

Three sentences:

- The bound claim was false in both halves. `repeat(1000)` -> `repeat(200)`
  leaves `concurrent claims …` green and fails the exhaustion test with
  "last candidate refused was a (200).txt" — reproduced. So this test is the
  only thing pinning the bound, and it reports a shrunk bound as a strike
  failure, which sends the next reader to the wrong line. The comment now
  says so; the description's claim to the opposite is gone.
- "a state `downloadToAppDownloads` never reaches" contradicted the
  paragraph three lines above it, which says a directory that refuses every
  rename exhausts the loop. Production can reach the backstop; what it
  cannot produce is a missing temp, and that is what the sentence says now.
- "a refusal is not something a host can be asked for" is contradicted by
  the test's own "refuses every fill deterministically, on any host" twelve
  lines later. The claim the seam actually needs is narrower and true: no
  host refuses a fill whose source exists.

The nits:

- The injected lambda restated the production default instead of delegating
  to it. The default is now a named `atomicMove`, and the test calls it for
  the rounds it lets through — a copy of that line is a copy that can drift,
  and it would drift in the direction that matters, since `REPLACE_EXISTING`
  there is what reopens the race.
- No default-move fixture was laid out the way production lays it out. `a
  claimed name lands the bytes …` now keeps its partials in `PARTS_DIR`, so
  the cross-directory move §4 leans on has a fixture behind it.
- The exhaustion test's same-filesystem parenthetical was about production,
  not about its own fixture, whose partial is in `downloads` and never
  exists. Attached to the right one.
- A comment wrap broke mid-identifier across three lines.

`versionName` 0.31.1 -> 0.31.2: the diff is no longer comment-only, and
CLAUDE.md puts invisible internals under patch.

`spotlessCheck`, `lintDebug` and `testReleaseUnitTest` green with
`--rerun-tasks`: 117/117, `DownloadNamingTest` 14/14. Both mutations quoted
above were run: `repeat(200)` fails the exhaustion test alone, and
`atomicMove` -> `REPLACE_EXISTING` still fails the concurrency test, so the
seam cost no coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
julian changed title from test(download): pin the refusal branch, and stop the KDoc overstating three things to refactor(download): pin the refusal branch, and stop the KDoc overstating three things 2026-09-02 13:01:54 +02:00
julian merged commit 2cb94b47b4 into main 2026-09-02 13:06:35 +02:00
julian deleted branch fix/claim-final-name-followups 2026-09-02 13:06:36 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
OpsDeck/mobile!11
No description provided.