[Fix] Write a site's metadata as one read-change-write so a ticket's base survives an overlapping write, Fixes #172 - #454
Merged
Conversation
…ixes #172 Every write to a site's record read the whole record, awaited, and wrote back what it had read. `mergeBranchMeta` did this for the `branches` map: read the map, await the store, write the map. A ticket started inside that await was written by the other flow's stale map, and its recorded trunk base, the one value a branch cannot recompute, was gone. The overlap the issue names is a ticket linked while a trunk update is finishing, and the new layer-3 test stages it inside every store access the finishing update makes; on the old code it fails at exactly one of them. `changeSiteMeta` is now the only writer: it awaits the store, then reads, applies the caller's change and writes with no yield in between, so the event loop is what serialises writers. `mergeSiteMeta`, `mergeBranchMeta`, the delete handler's map rewrite and the migration's map write all go through it; the migration additionally yields to one that finished during its git work. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEj32Dunw3y321YzBXNiWS
Self-review findings, all four of the same class as #172 and all with a test that is red on the code it corrects. `wordpress:setup` held the whole site map, not one record, across the Git spawn that reads the new clone's trunk info, so a write to any other site during it was undone. The read now happens before the record is touched, and the write is one `changeSiteMeta`. `sites:set-ticket` read a branch's recorded base on the existing-branch path and wrote it straight back, rolling back a `branches:rebase` that moved it while the switch ran. The base is now written only by the flow that created the branch. The migration's catch handed back its opening read, which by definition has no `branches` key. A migration that lost the race throws `branch-exists` before the new guard, so the loser reported an unmigrated site and `branches:rebase` refused a ticket whose base was on record. It now re-reads. AGENTS.md claimed every write goes through `changeSiteMeta`; eight handlers write the store directly and are correct as they are. It states the rule instead: nothing may yield between the read and the write. Also bounds the wait the #172 test makes inside a held store access, so the mutex-shaped fix for the same bug fails the test instead of hanging CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEj32Dunw3y321YzBXNiWS
…eared Second self-review pass. `branches:rebase` keeps the applied-patch record and drops only its text, and it read that record, resolved the write scope (a Git spawn), and then wrote back what it had read. A discard or an apply landing in that window was replaced by the record from before it, leaving a revert banner for a patch that is not on disk and a Revert with no hunks to find. The last writer of the #172 shape, and the one that costs a contributor a patch rather than a base. `changeWorkMeta` answers the scope question first, since that is the only part that cannot be answered without yielding, and decides what to write from the work meta as it is at the moment of the write. Also from the pass: the migration's race-loser branch now logs the error it returns past, so a genuine failure that coincides with another flow finishing the migration is not swallowed; and the two staged tests guard on the overlap having happened rather than on how many times a flow reaches the store, which is the implementation detail their own comments warn about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEj32Dunw3y321YzBXNiWS
Third self-review pass, no [fix here] findings. Its follow-up and both of its observations, all small: `changeWorkMeta` re-derived the branch from HEAD, spending a Git spawn on a question `branches:rebase` had already answered and could answer differently. It takes the ref now, like `writeWorkMetaOn` beside it and for the same reason, and it is one function rather than two. The scope is still resolved before the store is awaited, so the entry it names can be deleted in between. The write is now conditional on that entry still being there, rather than re-creating a branch record holding one work field and no branch point. The rebase/discard test asserts the rebase's own record write landed, the same guard the sibling test carries: without it a hold that stopped the flow early would assert nothing about an overlap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEj32Dunw3y321YzBXNiWS
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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.
Why
Every write to a site's stored metadata read the whole record, changed one field and wrote it back. Two writes that overlap lose one of the two changes. Harmless while the fields were labels and flags; since #108 one of them is a branch's recorded trunk base, written once when the branch starts and read for the rest of its life as the base every patch for that ticket is measured against. Lose it and the patch is generated against the wrong trunk: not empty, not refused, wrong.
The overlap the issue names is a ticket started while a trunk update is finishing. It is reproduced here, and so are five more windows of the same shape that the self-review turned up.
What changes
The root cause is narrower than the issue's framing. The store's
getandsetare synchronous, so the event loop already serialises writers, andmergeSiteMetaon its own was atomic: nothing yields between its read and its write. What was not atomic was every writer that computed what it stored from a read taken anawaitearlier.changeSiteMeta(sitePath, change)is now the helper for those: it awaits the store, then reads, applies the caller's function to the record as it is at that moment, and writes, with no yield in between.changeWorkMetaOnis the same idea one layer up, for the per-branch work meta whose scope has to be resolved before anything can be written.Six writers were losing changes, each a real lost write rather than a theoretical one:
mergeBranchMetaread thebranchesmap, awaited the store, and wrote that map back. A branch another flow started inside that await was written out of existence, base and all. This is the one the issue describes.wordpress:setupheld the whole site map, not one record, across a Git spawn that reads the new clone's trunk info. A write to any other site during it was undone. The read now happens before the record is touched.sites:set-ticketread the branch's recorded base on the existing-branch path and wrote it straight back, which rolled back abranches:rebasethat moved the base while the switch was running. The base is now written only by the flow that created the branch; the other path leaves it alone rather than rewriting it to the value it just read.branches:rebasekeeps the applied-patch record and drops only its text, and it read that record, resolved the write scope with a Git spawn, then wrote back what it had read. A discard or an apply landing in that window was replaced by the record from before it, which is a revert banner for a patch that is not on disk and a Revert with no hunks to find. This is the one that costs a contributor a patch rather than a base.branches:deleterebuilt the map from an earlier read, the same waymergeBranchMetadid.migrateSiteToBranchesis the one write that cannot be a single read-change-write, because its Git work sits between the read and the write. It now yields to a migration that finished in the meantime, and its catch re-reads instead of handing back the record it opened with. That last part was a bug of its own: a migration that loses the race throwsbranch-existsbefore the guard, so the loser reported an unmigrated site andbranches:rebaserefused a ticket whose base was on record.AGENTS.md carries the rule under the persistence bullet, stated as the rule itself rather than as a claim about which helper gets called: nothing may yield between reading the store and writing it back.
Nothing about what is stored changes. Same keys, same shapes, no migration needed.
How to test this
Platforms: any. Nothing here touches spawning, paths, or line endings. Every command below runs in the repository directory.
Six new layer-3 tests in
tests/unit/ipc-wiring.test.cjs, each verified red against trunkf31e5dfand green at head:The instrument is the stubbed
getStore. Three of the tests run one handler and land a second handler inside each store access the first one makes, one subtest per access, holding the first until the second has run to completion;AsyncLocalStorageis what lets a single stub tell the two flows apart. Iterating over every access rather than picking one matters: the count is an implementation detail, and these fixes changed it, so a test pinned to the access that fails today would pass vacuously tomorrow.branches:rebasesays "no recorded starting point"One thing to know when reading the staged tests, because it is not obvious: the hold sits inside the stubbed
getStore, never between agetand itsset, so the nested flow never sees a half-written store, which is a state the real one cannot be in. For the two pairs that live in one process the real window between read and write is microtask-only, so the staging opens a wider window than the app can. The lost-write shape is identical and reachable by two overlapping microtask chains; the clone and migration tests sit across genuine Git spawns, where the window is as wide as it looks.By hand, on either platform, on a packaged build of this branch's head: link a ticket, run Update to latest trunk, and as soon as
Updated to the latest trunkappears link a second ticket. Open the second ticket's card, and Create patch must produce a diff of that ticket's changes only. The window is a few milliseconds, so a manual pass is not evidence either way. It was not run for that reason, and the tests are what covers this.What must not have happened:
baseOidkey at all rather than an explicitnull; every reader treats the two the same, which is whybranches:listand the patch refusal are unchanged.Risks and limitations
Review outcome: 5
[fix here]· 6[follow-up]over three passes, all 5 fixed and 4 of the follow-ups taken. Details in the collapsed section.currentBranchandtracTicket. With a link landing inside the update's return, whichever writes last names the active ticket, and HEAD may say the other. That is field-level last-writer-wins, not a lost write: both values are recomputable from HEAD, andactiveBranchreads HEAD first. Recorded here rather than opened.workMetaScopestill decides a scope by reading, then writes after a yield. If a branch entry appears in that window the flag is filed at site level while the reader looks on the branch. The result is a flag written where nobody reads it, which is the False "Update incomplete" banner on trunk after an update run from a ticket #419 shape, not a dropped base, and no other writer's data is destroyed. Left as is rather than folded into the change callback, because it would rework the work-meta path [Fix] the false Update incomplete banner on trunk after an update run from a ticket #446 has just settled.baseOid: nullfor it, and if it writes first the winner yields to that. Null is the deliberate answer for a branch this app did not create (When a ticket's base is unknown, the app guesses instead of saying so #308): the ticket refuses patch operations rather than guessing. So the failure mode is a refusal a re-link clears, not a wrong patch, and closing it would mean ordering two migrations rather than just stopping them clobbering each other.Related
Fixes #172. Follows #446 (Fixes #419), which moved the incomplete flag onto the returning branch and named this mechanism on the way past. #108 introduced the recorded base, #308 is why a branch without one refuses rather than guesses, and #328 is why a rebase keeps the applied-patch record it strips.
Design decisions and alternatives considered
The issue offered three shapes: serialise writes to the record, make the write take the field rather than the record, or guard the one value that cannot be lost.
A per-site lock was rejected as more machinery than the bug needs. The store's own calls are already synchronous, so a lock adds nothing a synchronous read-change-write does not, and it brings the usual costs: every writer has to remember to take it, one that awaits inside it holds everyone else, and a forgotten one is invisible until it races. It would also have deadlocked the test that reproduces the bug, since the held update would own the lock the link waits for. That the test hangs under that fix rather than failing was itself a review finding, and the wait is now bounded so a future attempt gets a readable failure instead of a silent CI hang.
Per-field writes do not fit the value at stake.
baseOidlives insidebranches[ref], a nested map that is replaced whole, so a field-level write ofbranchesis exactly the whole-map write that lost the base. A path-level write would work but is a second write API beside the merge, and the merge is already a per-field write at the top level.A guard for
baseOidalone, refusing to write a map that drops an entry the store still has, would have protected the one value and left the pattern in place for whatever is stored next, which the issue names as the reason not to. The applied-patch record the rebase used to restore is that next value, and it was already there.changeSiteMetais the smallest of the four: it changes where the read happens, not what is written, and it removes the pattern rather than guarding one instance of it.One thing considered and declined: a runtime guard rejecting an
asyncchange function, which would otherwise store a Promise that serialises to{}and wipe the record. Every call site is in the same file and synchronous, the JSDoc says so, and a defensive check for a programmer error the surrounding code makes obvious is the speculative kind this repo's complexity rule asks not to add.Review outcome (required — see AGENTS.md)
5
[fix here]· 6[follow-up]over three passes. All 5 fixed; 4 of the 6 follow-ups taken as well because each was a few lines; the 2 deferred are in Risks and limitations with their reasons.Fixed:
b5f24ff)[fix here]:wordpress:setupheld the whole site map across the Git spawn that reads the clone's trunk info, so a write to any other site during it was undone. Read hoisted, write throughchangeSiteMeta.a651d4f, with a test red onb5f24ff.[fix here]:sites:set-ticketread a branch's recorded base and wrote it back a yield later, rolling back abranches:rebaselanding in between. The read is gone and the base is written only by the flow that created the branch.a651d4f, test red on trunk.[fix here]: the migration's catch returned the record from before its git work, so a migration that lost the race reported an unmigrated site andbranches:rebaserefused a ticket whose base was on record. Re-reads now.a651d4f, test red onb5f24ff.[fix here]: the AGENTS.md sentence claimed every write goes throughchangeSiteMeta; eight handlers write the store directly and correctly. Restated as the rule itself.a651d4f.a651d4f)[fix here]: the migration's race-loser branch swallowed the error it returned past. Logged.b7303ee.[follow-up]: the staged test hangs rather than fails under a mutex-shaped fix, andnode --testsets no timeout. The held wait is bounded with a message naming the deadlock.a651d4f.[follow-up]:branches:rebaseread the applied-patch record, resolved the scope with a Git spawn, then wrote back what it had read, so a discard or apply landing in the window was replaced by the old record. Taken as a fix rather than deferred: it is the same class, it was the one live counter-example to the rule this PR writes into AGENTS.md, and it costs a patch rather than a base.changeWorkMetaOn.b7303ee, test red on trunk and ona651d4f.[follow-up]: two guards pinned the number of store accesses a flow makes, which the tests' own comments call an implementation detail. Replaced by guards that the overlap happened.b7303ee.b7303ee)[follow-up]: the work-meta change re-created a branch entry that had been deleted between the scope decision and the write. The write is conditional on the entry still being there.6ec7e7e.6ec7e7e: the helper re-derived the branch from HEAD with a second Git spawn when its one caller had the ref in hand, so it takes the ref now likewriteWorkMetaOn; and the rebase/discard test asserts the rebase's own write landed, the guard its sibling already had.Deferred:
[follow-up]:workMetaScopedecides a scope by reading, then the write follows a yield. The worst case is a flag written where nobody reads it, not a lost base; reworking the path [Fix] the false Update incomplete banner on trunk after an update run from a ticket #446 just settled is not worth it here.[follow-up]: two flows can still move HEAD at once; this PR fixes the store half of that concurrency, and the mid-switch marker only refuses once a checkout has already failed.6ec7e7e. The check reads pass; its comment reads "Review limit reached, next included review available in 55 minutes". Merged inside that window on the author's decision, so no retry was requested; the three fresh-context passes above are the review coverage for this change..github/instructions/code-review.instructions.mdonly), three passes per step 3 of that file. Pass 1 headb5f24ff, pass 2 heada651d4f, pass 3 headb7303ee; baseorigin/trunkf31e5dfthroughout. Working tree clean at each pass; no uncommitted or untracked files in scope. Deterministic layer run by the author and reported to each pass:npm run lintclean,npm testandnpm run test:electrongreen at every head (1312 tests at6ec7e7e).b5f24ff→a651d4f→b7303ee, each re-reviewed in a fresh context rather than carried forward; pass 2 reconciled all four pass-1 fixes as resolved, pass 3 reconciled all five earlier fixes as still in place.b7303ee→6ec7e7eis pass 3's own follow-up and observations: one function renamed to take a ref and drop a spawn, one guard line, one test assertion. No fourth pass was run; the six Overlapping writes to a site's metadata can drop a ticket branch's patch base #172 tests are red on trunk and green at6ec7e7e, and the full suite passes on both runtimes.Implementation notes
src/main.jschangeSiteMeta(sitePath, change)sits abovemergeSiteMeta, which is now that function with a shallow merge. It returns the record it wrote, which is whatmigrateSiteToBrancheshands back.changeWorkMetaOn(sitePath, ref, change)resolves the scope first, because that is the only part that cannot be answered without yielding, then does one read-change-write against the ref its caller already has. Returning null from the change writes nothing, and so does an entry deleted between the scope decision and the write.mergeBranchMetaandbranches:deletebuild thebranchesmap inside the change function.wasActivein the delete is still decided from the HEAD read before it, because that is a question about the checkout, not about the record.sites:set-ticketleavesbaseOidundefined on the existing-branch path, and the patch spreads it only when it is set. An entry created there has nobaseOidkey rather than an explicit null;patchBaseOid,branches:list,branches:rebaseandsite:statusall read the two the same way.migrateSiteToBranchesguards its write withnow.branches ? now : …and its catch with a re-read, which are the same question asked at the two places the race is observable. The catch logs the error it returns past, so a genuine failure that coincides with another flow finishing the migration is not swallowed.tests/unit/ipc-wiring.test.cjsnode --testsets no timeout of its own.ticket-branches, not on a flag the loser itself sets, or the loser wins and there is no race left to test.Screenshots or recording
Nothing on screen changes. The fix is in what is stored, and the values stored are the same ones as before.
🤖 Generated with Claude Code
https://claude.ai/code/session_01LEj32Dunw3y321YzBXNiWS