refactor(download): pin the refusal branch, and stop the KDoc overstating three things #11
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/claim-final-name-followups"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 onIllegalStateException("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": thecreateNewFile→"cannot write to …"branch atSchemaViewModel.kt:430-436still 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)withn = 8and 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— atempthat does not exist refuses every fill deterministically, on any host, and drives the loop to its backstop. Asserts the message (assertEqualsover the part before" in ", so a failure prints what it got); the cause (java.nio.file.NoSuchFileException, spelled at the use site becausekotlin.iohas 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
c855bedon Linux:lost += candidatefrom the refusal branchlast candidate refused was a.txt, andexpected:<a (2).txt> but was:<a.txt>if (lost.size < 1) lost += candidatelast candidate refused was a (2).txttarget.delete(); if (temp.exists()) target.createNewFile()expected:<[.parts, a (2).txt]> but was:<[a (2).txt, a.txt, .parts]>target.delete()[a (5611506248938).txt, a (999).txt, …]refusedfrom the throwcause was nullThe exhaustion test is also the only thing pinning
repeat(1000). An earlier revision of this description said the opposite — that the bound was held byconcurrent claims …and not claimed here — and review disproved it:repeat(200)leaves the concurrency test green and fails the exhaustion test, because theassertFalseat the end only holds while the bound exceeds the 999uniqueNamecounts 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
claimFinalNameis reached fromdownloadToAppDownloads, theelseofSDK_INT >= Q. From Android 10 a download goes throughdownloadViaMediaStore: the insert settles a pending row under the fallback name, and the served name is applied afterwards byresolver.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. TheATOMIC_MOVEargument never needed a count of hosts and no longer carries one: on WindowsrenameTorefuses 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:
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_EXISTINGthere is what reopens the race, and it is caught only because the concurrency test goes through the default.downloadToAppDownloadspasses 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
7c18ffdreviewif (lost.size < 1)survived. One strike per round over 1000 rounds walksuniqueNameoff the 2..999 it counts to, so the last candidate must be the nanoTime fallback. Asserted as two exclusions (nota.txt, not a numberuniqueNamecounts to) rather than as a value.[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
c855bedreviewThe three sentences the approval asked for before merge, and the four nits.
repeat(200)fails the exhaustion test alone).SchemaViewModel.ktcontradicted itself about the backstop. "A statedownloadToAppDownloadsnever 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.atomicMoveabove.a claimed name lands the bytes …now keeps its partials inPARTS_DIR, so the cross-directory move §4 leans on has a fixture behind it; every other call site keeps its partial indownloadsitself.downloadsand never exists. Attached to the right one.Gates
spotlessCheck,lintDebugandtestReleaseUnitTest— the three CI runs — green locally at the head, forced with--rerun-tasks.DownloadNamingTest14/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, andatomicMove→REPLACE_EXISTINGstill fails the concurrency test, so extracting the default cost no coverage.versionName0.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 fromtest(download):for the same reason.🤖 Generated with Claude Code
Re-reviewed at
6b8ec4ain 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
spotlessCheck,lintDebug,testReleaseUnitTestgreenBUILD SUCCESSFULlocally.DownloadNamingTest13/13tests="13" failures="0" errors="0". New test costs 54 ms.target.delete()fails the testexpected:<[]> but was:<[a (596442187657244).txt, a (999).txt, …]>— 1000 corpses, exactly the property claimed.refusedfrom the throw fails the testNoSuchFileExceptionwould silently resolve tokotlin.io'sminSdk = 26,VERSION_CODES.Q = 29, andclaimFinalNamehas exactly one production caller, inside theelse. Android 8.0 / 8.1 / 9 is right.versionNamebumpThat 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-latestin 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 += candidatefrom the refusal branch and all 13 tests stay green. It has to survive — with atempthat 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 pasta.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
The MediaStore paragraph attributes the final name to the insert. In
downloadViaMediaStorethe insert only settles a pending row fromfallbackName; the served name is applied by the laterresolver.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."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.
Two absolutes ("the release only fails on Windows", "on Linux a fill is never refused") are wider than their evidence.
Two nits on the new test:
assertTrueon 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 —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
downloadViaMediaStorethe insert does not settle the display name. It settles a pending row underfallbackName, which at that point is a guess. The final name is set afterwards, once the response headers have arrived:That
updateis the operation that collides — the code carries a comment saying exactly that ("the served name collides with a row that already exists") — anddisplayNameOfexists 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"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 doeslost += candidateand 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 backMinor, 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_MOVEacross a filesystem boundary throwsAtomicMoveNotSupportedExceptionon Linux. It cannot happen here —PARTS_DIRis a child ofdir— 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 aThis 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.ymland.forgejo/workflows/pr-checks.ymlboth sayruns-on: cth-ubuntu-latest, andgrep -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:
@ -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 fillThe 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 onlylost += candidatefrom the refusal branch:The mutant survives, and it has to: without the strike,
uniqueNamehands backa.txton every one of the 1000 rounds, each claim is created and then deleted, the loop still exhausts, the throw still carriesNoSuchFileException, and the directory is still empty. Every assertion in the test is satisfied by aclaimFinalNamethat never numbers past anything.The bound is not pinned here either —
repeat(1000)→repeat(1)failsconcurrent 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
tempthat 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(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 giveassertTrueits message argument, or assert on the substring so the diff prints:@ -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,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'scontinues the previous sentence.The rationale is worth keeping — I verified it: delete the
import java.nio.file.NoSuchFileExceptionline and the file still compiles (kotlin.io.NoSuchFileExceptionis 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.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 oneruns-onin both workflows.Blocking 2 — the strike was not pinned. Took the "pin it" way out, without a seam. A missing
temprefuses every candidate identically, as you said — but the refusal names the candidate:UnixCopyFile.moveandWindowsFileCopy.moveboth rethrow a failedATOMIC_MOVEviarethrowAsIOException(source, target), soNoSuchFileException.otherFileis the last target tried. A loop that struck nothing asks fora.txta thousand times; one that strikes ends on something numbered past it. Asserted asstartsWith("a (")rather than a particular number — which number isuniqueName's business and the nanoTime fallback is one of them — with anassertNotNullonotherFilefirst, 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 withcause 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.updateto 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
assertEqualsoversubstringBefore(" in "), your form. For the import: rather than a comment on the import line I fully qualifiedjava.nio.file.NoSuchFileExceptionat 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 inDownloadNamingTest, 116/116 overall) green.lintDebuggreen too, but only after escaping the untrackedlocal.properties(sdk.dir=C\:/Android/Sdk) — lint'sPropertyEscapefailed it on this desk first; nothing tracked changed for that. Description updated to match.Re-verified at
7c18ffdin a throwaway worktree:spotlessCheck,lintDebugandtestReleaseUnitTestall green (116/116,DownloadNamingTest13/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 againstSchemaViewModel.kt:1066-1069and:1204-1246, andNoSuchFileException.otherFilereally 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:408to strike only the first refusal —— and all 13 tests stay green: the loop then re-asks for
a (2).txt998 times, and the assertion at line 238 is satisfied by any candidate that is nota.txt. The property actually pinned is "the last candidate was numbered pasta.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 thenanoTimefallback, sokills 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
tempis missing, anddownloadToAppDownloadsalways creates its temp (SchemaViewModel.kt:1300). Conditioning the release on that same precondition —— is 13/13 green, i.e. a
claimFinalNamethat leaks a zero-byte corpse on every refusal a device can actually hit passes the suite. Related: no test ever observesclaimFinalNamereturning 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:
:1246isdisplayNameOf(...) ?: name, so a failed query reports the predicted name.delete()that fails is the other" closes the set at two; a non-IOExceptionafter the claim strands the placeholder with no crash either.ATOMIC_MOVEon Linux. "has not produced it" holds.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.target.delete()), and carries no note.SchemaViewModel.kt:393-397: thecreateNewFile→ "cannot write to …" branch is also unpinned, so "the one part of the function nothing pinned" is still not exhaustive.repeat(200)withn = 8(lines 154, 157). That number came from an instrumented run in #7, not from the test the sentence names.[downloadToAppDownloads]is a private member ofSchemaViewModelreferenced from a top-level function's KDoc — it does not resolve from there.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 += candidatedid survive: the loop re-asksa (2).txt998 times andstartsWith("a (")is satisfied by any of them.One strike per round and 1000 rounds walks
uniqueNameclean 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:Both are needed: your
assertFalsealone lets the no-strike mutant through (a.txtdoes not match the pattern either), and theassertTruealone is what let the strike-once mutant through. Together:lost += candidatelast candidate refused was a.txtif (lost.size < 1) lost += candidatelast candidate refused was a (2).txtThe 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.The default is the call it replaced and
downloadToAppDownloadspasses 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.partsis empty:target.delete(); if (temp.exists()) target.createNewFile()expected:<[.parts, a (2).txt]> but was:<[a (2).txt, a.txt, .parts]>target.delete()refusedfrom the throwcause was nullThat also closes your related point:
assertEquals(2, fills)plusassertEquals("a (2).txt", target.name)is the first assertion anywhere that observesclaimFinalNamereturning 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
?: namereports the predicted name when it fails. Now says so: "a saved file under a label that may be wrong, never a saved file lost."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".[PARTS_DIR], not on another mount. The new seam test uses that layout.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.repeat(200)/n = 8alongside.[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,testReleaseUnitTestgreen atc855bedon Linux, forced with--rerun-tasksrather than trusting the cache.DownloadNamingTest14/14, 117/117 overall. Five mutants die by assertion, none by compile error.No
versionNamebump: 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
Approving. Re-verified at
c855bedin a throwaway worktree: I ran the gates and every mutation myself rather than reading the table.All three blockers are closed, by mutation:
if (lost.size < 1) lost += candidate(blocker 1's survivor)last candidate refused was a (2).txttarget.delete(); if (temp.exists()) target.createNewFile()(blocker 2's survivor)expected:<[.parts, a (2).txt]> but was:<[a (2).txt, a.txt, .parts]>lost += candidatetarget.delete()[a (56416304866543).txt, a (999).txt, …]refusedfrom the throwcause was nullYour 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 theassertFalse. Neither alone is enough.Four more mutants of my own, all dead — so the seam did not cost coverage:
if (refused == null) refused = e) →last candidate refused was a.txtmove→REPLACE_EXISTING→ concurrency test,expected:<8> but was:<7>move→ delete-then-rename → samerepeat(1000)→repeat(200)→ see belowThat second one is worth noting: it means the KDoc's central argument at
:355-362— thatREPLACE_EXISTINGreopens 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-343matchesdisplayNameOf(resolver, uri) ?: nameat:1270, the MediaStore mechanism matches:1229-1270step for step, andcannot write toreally is still unpinned. #10's corrections section is accurate on all five.Gates on my host:
spotlessCheck,lintDebug,testReleaseUnitTestallBUILD SUCCESSFULwith--rerun-tasks. 117/117,DownloadNamingTest14/14. YourPropertyEscapefailure did not reproduce — that one is specific to a Windowssdk.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.
The bound claim is false, in both halves. "The bound (
repeat(1000)) is pinned byconcurrent claims …, not here; this PR does not claim it."repeat(1000)→repeat(200)leavesconcurrent claims …green and kills your exhaustion test:last candidate refused was a (200).txt. TheassertFalseat:254-257only holds while the bound exceeds the 999uniqueNamecounts 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.SchemaViewModel.kt:378-380contradicts:371-373. "driving every candidate into the backstop — a statedownloadToAppDownloadsnever 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, whichdownloadToAppDownloadsnever produces" says the true thing and keeps the argument.SchemaViewModel.kt:375-376is a new absolute. "a refusal is not something a host can be asked for" is contradicted by your ownDownloadNamingTest.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 defaultmove— every default-move call site (:122,:130,:140,:169,:222) keeps its partial indownloadsitself. Moving one sequential fixture's partial intoPARTS_DIRand 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 isFile(downloads, ".a.txt.gone.part")(:220), and it never exists. The parenthetical is about production; attach it there.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.test(download):no longer covers a production signature change. The body is clear about it, so this only matters to someone reading the log.test(download): pin the refusal branch, and stop the KDoc overstating three thingsto refactor(download): pin the refusal branch, and stop the KDoc overstating three things