Repository navigation
fix(cases): serialize every write to linked-cases.json - #764
Merged
Merged
Conversation
Five routes changed the linked-cases registry: link, unlink, rename, and a
create or clone into a custom folder. Each read the file, checked it and wrote
it back on its own, so two requests that both read before either wrote lost
an entry. Two clones that claimed one name and finished together both passed
the name check, both answered success, and the later write replaced the
earlier entry. The same race made "a name linked while the clone ran is a
409" in test/routes/case-clone-custom-folder.test.ts fail about half the time.
- New src/web/linked-cases-writer.ts, mutateLinkedCases(), shaped like
mutateSettingsFile(): mutations run one at a time, each write is a temp
file renamed over the registry (through a symlink, keeping its mode), and
a file that is not a JSON object is refused (409 CONFLICT) instead of being
read as {} and overwritten.
- All five writers go through it, with their name checks inside the
callback, so exactly one of two requests for one name wins. Rename also
re-checks that the old name still points at the old folder, and drops its
manual registry undo, since a failed write no longer changes the file.
- Tests: test/linked-cases-writer.test.ts (writer behaviour, and concurrent
link/unlink through the routes with slowed writes, which fail on the old
code). Tests that forced a failed registry write with chmod 444 or a
directory now inject the failure at the rename, or expect the refusal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ark0N
pushed a commit
that referenced
this pull request
Nov 6, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner
Author
|
In the 1.49.0 release. The rule holds for registry entries. A local case's rename racing a new link is not covered, and the release note says so. |
This was referenced Nov 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
linked-cases.jsonin the data dir maps a linked case's name to its folder. Five routes insrc/web/routes/case-routes.tschange it:POST /api/cases/linkDELETE /api/cases/:name(unlink)POST /api/cases/:name/renamePOST /api/caseswith a custompathPOST /api/cases/clonewith a custompathEach one read the file, checked it, and wrote it back with a plain
fs.writeFile, and nothing kept two requests apart. A clone into a custom folder checks the name before git runs, and again right before its write. The code's own comment said this "only narrows the window". So two clones that claimed one name and finished together both passed the second check, both answered success, and the later write replaced the earlier one's entry. The user who lost was never told. Any two of the five writers can lose an entry the same way: a link and an unlink at the same moment, for example, can drop the new link.The same race made a test flaky on master.
test/routes/case-clone-custom-folder.test.ts> "a name linked while the clone ran is a 409 that keeps the folder and names it" failed 4 of 6 local runs on 2aa6016 (1.48.0). It also failed the CI of an unrelated PR (#763) and passed there on a rerun.The fix
A new
src/web/linked-cases-writer.tswithmutateLinkedCases(fn), the same shape asmutateSettingsFile()insrc/web/settings-writer.ts:undefinedto write nothing. A check that must still hold when the entry is written runs inside the callback: throwing there rejects that caller only and writes nothing.{}. A file that cannot be read or is not a JSON object is left byte for byte and the write is refused with409 CONFLICT. Before, such a file read as{}, and the next link wrote back its own entry alone, unlinking every other case.All five writers go through it. Each name check now happens inside the callback, so of two requests that claim one name exactly one wins and the other gets the existing
409 ALREADY_EXISTS. For the clone, the loser keeps its folder, as before. The rename also re-checks that the old name still points at the old folder; if not, it answers 409 and moves the folder back. Its manual "write the old registry back" undo is gone, because a failed write no longer changes the file. Readers still read the file directly.Responses are unchanged except in two cases:
409 CONFLICTinstead of being overwritten.Tests
New
test/linked-cases-writer.test.ts. Writes to the registry are slowed by 60 ms, so two writers that both read before either writes would lose an entry every time.The four route tests fail against the old
case-routes.tsand pass with the fix.The flaky clone test passed 20 of 20 runs with the fix.
Tests that forced a registry write failure updated:
chmod 444, which no longer stops a temp file renamed over the registry. They now inject the failure at that rename, as the settings-writer tests already do.case-routes.test.ts: its mockedfsgained the calls the writer makes.npm run typecheck,npm run lint,npm run format:checkand the fullnpm testpass.CLAUDE.md(Case rename) anddocs/architecture-invariants.md(Clone Repo) say where linked-cases writes go now.