Skip to content

fix(cases): serialize every write to linked-cases.json - #764

Merged
Ark0N merged 1 commit into
masterfrom
fix/linked-cases-race
Nov 6, 2026
Merged

Ark0N merged 1 commit into
masterfrom
fix/linked-cases-race

Conversation

@Ark0N

@Ark0N Ark0N commented Nov 5, 2026

Copy link
Copy Markdown
Owner

The bug

linked-cases.json in the data dir maps a linked case's name to its folder. Five routes in src/web/routes/case-routes.ts change it:

  • POST /api/cases/link
  • DELETE /api/cases/:name (unlink)
  • POST /api/cases/:name/rename
  • POST /api/cases with a custom path
  • POST /api/cases/clone with a custom path

Each 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.ts with mutateLinkedCases(fn), the same shape as mutateSettingsFile() in src/web/settings-writer.ts:

  • Serialized. Mutations run one at a time on one promise chain. The callback gets the registry as it is now and returns the new one, or undefined to 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.
  • Atomic. The new file is written to a temp file beside the target and renamed over it, through a symlink if there is one, keeping the file's permissions. A reader never sees half a file. Before, quick-start could read a half-written registry as empty and resolve a linked case to the cases dir. A failed write now leaves the previous file in place.
  • Refuses a file it must not trust. A missing or empty file starts from {}. A file that cannot be read or is not a JSON object is left byte for byte and the write is refused with 409 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:

  • A registry that does not parse is now refused with 409 CONFLICT instead of being overwritten.
  • A conflict detected only at write time now gets 409 where the old code could answer success.

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.

    • Writer: concurrent mutations all land; a callback that throws rejects only its caller; missing and empty files are handled; non-object files are refused untouched; a failed write leaves the old file and no temp file; a symlinked registry is written through with its permissions kept.
    • Routes: two links of different names both survive; two links of one name give exactly one winner; an unlink racing a link keeps the new link; a link refuses a registry that does not parse.

    The four route tests fail against the old case-routes.ts and pass with the fix.

  • The flaky clone test passed 20 of 20 runs with the fix.

  • Tests that forced a registry write failure updated:

    • Rename tests: four used 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.
    • Custom-folder tests: two made the registry a directory. One now injects a failed rename and still expects 500. The other covers the new refusal (409, scaffold undone, file untouched).
    • case-routes.test.ts: its mocked fs gained the calls the writer makes.
  • npm run typecheck, npm run lint, npm run format:check and the full npm test pass.

CLAUDE.md (Case rename) and docs/architecture-invariants.md (Clone Repo) say where linked-cases writes go now.

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
Ark0N merged commit 5929584 into master Nov 6, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Nov 6, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Ark0N

Ark0N commented Nov 6, 2026

Copy link
Copy Markdown
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants