You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Read-modify-write spans the metadata seam, so a concurrent field update is lost #400
MetadataStorage owns its lock but not its transaction, so a concurrent update to a different field of the same record is lost.
exclusive (domain/metadata.rs:550) takes the flock and calls self.load() under it — correct, and the module doc at :20-24 says why. But the interface offers get_* and add_* and no update, so every caller does read-modify-write across the seam: the record handed to add_worktree was cloned before the lock existed, and add_worktree (:504) inserts it wholesale over the freshly reloaded map.
Call sites:
flows/repo_manager.rs:1429-1436 — clone, set last_fetched, add
flows/workspace_clone.rs:960-961 — clone, set devpod_workspace_id, add
flows/lifecycle.rs:3068-3069 — clone, set devpod_workspace_id, add
flows/migration.rs:306 — clone, set local_path, add
The reload does protect other records, and a_mutation_reloads_under_the_lock_rather_than_rewriting_a_stale_copy (:2443) pins exactly that. What it does not protect is a second field of the record being written.
How real is it
Not demonstrated, and worth measuring before acting — hence not filed as a bug. Losing data needs two dl runs writing the same worktree key concurrently, and serialize_launch (flows/launch.rs:904) holds a per-workspace flock across part of that window. The clone-preparation writes (workspace_clone.rs:960) look to fall outside it. Someone should establish whether the window is reachable before paying for the fix.
Shape
update_worktree(key, |w| ...) / update_repository(key, |r| ...), running the closure on the record loaded under the lock. The four call sites become one line each, and the record structs (domain/model.rs:199-223) can stop exposing their fields to the flows layer.
Proving it
Two processes, one worktree key, one field each, asserted afterwards — which is only writable once the update crosses the seam as a closure.
The ticket asks one thing — establish whether the window is reachable before paying for the fix. Answer in five parts: the launch flock is not what guards these writes, only two of the four sites are the shape described, the window is wide open, nothing observable travels through it, and the interface should change anyway.
1. The launch flock covers none of the four writes
serialize_launch is taken at flows/launch.rs:1089, inside up_under_stage — stage four. Every metadata write on the launch path happens in stage three: place_triple calls prepare (launch.rs:2656), prepare delegates whole to prepare_cold (launch.rs:2332), and carry_out — the function that eventually reaches workspace_up — runs only after place has returned a Placement. The launch lock is acquired after the last metadata write of the launch, never across one.
So the hedge in "How real is it" should be struck: serialize_launch holds nothing across any of these four writes, and the per-workspace lock drops out of the analysis entirely.
What does guard them is the per-repo lock, partially:
workspace_clone.rs:961 — under hold_repo_lock (workspace_clone.rs:535).
repo_manager.rs:1431 — under the repo lock too, from the other side: the only production caller is lazy_fetch ← sweep_repo_fetches, wrapped in locks::run_if_lock_free(&repos.lock_path(&owner, &repo), …) (lifecycle.rs:485), and lock_path (repo_manager.rs:1107) is the same path hold_repo_lock takes (:1132).
lifecycle.rs:3070 — no repo lock. Nothing but the metadata lock.
migration.rs:306 — see below.
2. Two of the four sites are not the shape the ticket describes
migration.rs:306 is already the proposed fix.commit_migration (metadata.rs:674) takes the metadata lock, reloads at :688, and hands the reloadedIndexMap to the edit closure; migrate_cache iterates worktrees.values_mut() (migration.rs:192) and migrate_record mutates the borrowed record in place. That is update_worktree(key, |w| …) under another name, already in this file and already load-bearing. Whatever gets built should generalise it, not invent a second mechanism beside it.
workspace_clone.rs:960-961 is not a read-modify-write.WorktreeInfo::new (model.rs:283) builds the record out of the launch's own inputs; nothing is read from storage. It is wholesale re-registration, which is what the comment above it says it is. It clobbers, but only with values it owns.
Two genuine cross-seam read-modify-writes remain: repo_manager.rs:1429-1436 and lifecycle.rs:3068-3070.
3. The window is reachable, and not by microseconds
The staleness is not the read-to-write distance — at repo_manager.rs:1429-1431 those are adjacent lines. It is the load-to-write distance, because get_repository reads the in-memory map, refreshed only by whichever exclusive() ran most recently (metadata.rs:562). Two measurements:
The sweeper.child_work (lifecycle.rs:381-387) is a detached child spawned on nearly every command. It loads its map at startup and then runs a full git fetch --all bounded only by BACKGROUND_FETCH_TIMEOUT = 300s (repo_manager.rs:82) before writing last_fetched back from that pre-fetch copy. Two dl processes writing metadata at once is the designed steady state here, not an edge case — domain/locks.rs:4-10 says so in as many words.
--reconcile. The plan is built at dl/src/commands.rs:997 and applied at :1020, with confirmed("Re-point these? [y/N] ") sitting at :1012 in between. The read-to-write window contains a human prompt, so it is unbounded.
Reachable, then, and for minutes.
4. And yet nothing is lost through it, because there is no field to lose
I enumerated every field a second process can move while one of the two sites holds its stale copy.
BaseRepository, at the sweeper's write. The sweeper holds the repo lock, and every writer of remote_url, local_path and default_branch holds it too — all of them reach storage through record (repo_manager.rs:1291), from clone_if_missing/register_existing_bare, which are RepoLock-gated. The one field a non-holder can move is worktrees, pushed by add_worktree (metadata.rs:508-510) and popped by remove_worktree (:532-534) on the reloaded record — and remove_clone (workspace_clone.rs:1278, the rm/--prune path) takes no repo lock, so it genuinely races the sweeper. But BaseRepository.worktrees is written by dl and read by nothing. The only non-test mentions in the tree are those two mutators and serde (model.rs:274). It survives because the on-disk shape is pinned against what Python wrote, and Python is gone — nothing outside test/ is a .py file any more.
WorktreeInfo, at apply_reconciliation's write. The revertible fields are local_path, workspace_id, devpod_workspace_id, created_at, last_used. created_at and last_used have no production reader either: dl ls takes last_used off the devpod listing (clients/devpod.rs:726 → listing.rs:883, :1114), never off the record. The other three are derived deterministically from (owner, repo, branch) through WorkspaceId, so the only concurrent writer of that key (workspace_clone.rs:960) writes byte-identical values.
That is the answer. The mechanism the finding describes is real; the "different field of the same record" it needs does not exist today. (b): the window is reachable, the bug is not.
5. What is not nothing
Two residuals through the same seam. Both real; neither prices a fix on its own.
add_worktree's insert (metadata.rs:506) is unconditional, so it resurrects a record a concurrent writer deleted. Plan a --reconcile, leave the prompt sitting, dl <ws> rm a workspace in that plan from another terminal, answer y — the record comes back naming a directory that is gone. No data is lost: the filesystem wins by design (repo_manager.rs:1572-1576) and the next --prune clears the row. But it is a lost delete, and update_worktree(key, |w| …) is a no-op on an absent key rather than a resurrection.
A concurrent --reconcile could revert local_path over migrate_cache's rewrite, and that one is observable, since rm follows local_path. It is also once per machine, on a pre-Build: parse workspace ids at one boundary (injective scheme) #64 cache, against a --reconcile running at that instant.
6. Change the interface anyway
The safety of repo_manager.rs:1431 rests on an argument I had to reassemble out of six files: which lock the sweeper happens to hold, which callers happen to be RepoLock-gated, and which fields happen to have no reader. None of it is written down at the call site and none of it is pinned by a test — a_mutation_reloads_under_the_lock_rather_than_rewriting_a_stale_copy (metadata.rs:2443) pins the neighbouring-records half and stops there. The day somebody reads worktrees, or adds a field to BaseRepository written from a path that takes no repo lock, the bug goes live and nothing goes red. That is exactly the reader-in-a-year cost principle 1 is about, and it is why "no live bug" is not the end of this ticket.
The interface is what makes it possible. add_worktree(WorktreeInfo) (metadata.rs:498) takes a whole record, so a caller with one field to change has no move but read-modify-write across the seam. The four call sites did not choose badly; there was nothing else to choose.
Principle 2 agrees rather than objects, because the change is a deletion. Two call sites lose their .cloned() + field-set + add_* and become one closure each. add_* stays — wholesale registration (workspace_clone.rs:960) is a genuinely different intent from moving a field, and add beside update is the sum of the two, not an extra verb. With the flows layer no longer constructing records it never read, the fields on BaseRepository/WorktreeInfo (model.rs:199-223) can drop to module-private, which is principle 3: "a record whose unseen fields the caller invented" stops being representable.
7. How it gets tested, and not the way "Proving it" says
The ticket proposes two processes, one worktree key, one field each. That test passes today — it has to, because §4 is the finding that no such pair of fields exists. Writing it would prove nothing and would leave the next reader believing a race was closed that was never open.
The behaviour genuinely absent needs no second process: domain/locks.rs:14-17 notes flock is per open file description, so a second MetadataStorage on the same path inside one test is a real second writer.
A field written to disk by another writer between this store's load() and its update_* survives the update, and only the closure's field moves. Red today under add_*, because the pre-lock clone overwrites it.
update_worktree on a key another writer has deleted writes nothing and reports so, rather than resurrecting the row. Red today at metadata.rs:506.
Graduating as a maintainability-and-modelling slice, explicitly not a bug fix, so nobody reads the resulting PR as having closed a live race.
Graduated as #412 on the map — update_repository/update_worktree as closures over the record loaded under the lock, the two genuine call sites converted, the other two left alone with the reason written down, and the record fields narrowed. Filed as maintainability and modelling, not as a bug fix: §4 above is why there is nothing to fix, and #412 carries the two seam tests that actually go red instead of the two-process one proposed here.
Activity
blooop commentedon Aug 24, 2026
agent-decided (long-term maintainability):
The ticket asks one thing — establish whether the window is reachable before paying for the fix. Answer in five parts: the launch flock is not what guards these writes, only two of the four sites are the shape described, the window is wide open, nothing observable travels through it, and the interface should change anyway.
1. The launch flock covers none of the four writes
serialize_launchis taken atflows/launch.rs:1089, insideup_under_stage— stage four. Every metadata write on the launch path happens in stage three:place_triplecallsprepare(launch.rs:2656),preparedelegates whole toprepare_cold(launch.rs:2332), andcarry_out— the function that eventually reachesworkspace_up— runs only afterplacehas returned aPlacement. The launch lock is acquired after the last metadata write of the launch, never across one.So the hedge in "How real is it" should be struck:
serialize_launchholds nothing across any of these four writes, and the per-workspace lock drops out of the analysis entirely.What does guard them is the per-repo lock, partially:
workspace_clone.rs:961— underhold_repo_lock(workspace_clone.rs:535).repo_manager.rs:1431— under the repo lock too, from the other side: the only production caller islazy_fetch←sweep_repo_fetches, wrapped inlocks::run_if_lock_free(&repos.lock_path(&owner, &repo), …)(lifecycle.rs:485), andlock_path(repo_manager.rs:1107) is the same pathhold_repo_locktakes (:1132).lifecycle.rs:3070— no repo lock. Nothing but the metadata lock.migration.rs:306— see below.2. Two of the four sites are not the shape the ticket describes
migration.rs:306is already the proposed fix.commit_migration(metadata.rs:674) takes the metadata lock, reloads at:688, and hands the reloadedIndexMapto the edit closure;migrate_cacheiteratesworktrees.values_mut()(migration.rs:192) andmigrate_recordmutates the borrowed record in place. That isupdate_worktree(key, |w| …)under another name, already in this file and already load-bearing. Whatever gets built should generalise it, not invent a second mechanism beside it.workspace_clone.rs:960-961is not a read-modify-write.WorktreeInfo::new(model.rs:283) builds the record out of the launch's own inputs; nothing is read from storage. It is wholesale re-registration, which is what the comment above it says it is. It clobbers, but only with values it owns.Two genuine cross-seam read-modify-writes remain:
repo_manager.rs:1429-1436andlifecycle.rs:3068-3070.3. The window is reachable, and not by microseconds
The staleness is not the read-to-write distance — at
repo_manager.rs:1429-1431those are adjacent lines. It is the load-to-write distance, becauseget_repositoryreads the in-memory map, refreshed only by whicheverexclusive()ran most recently (metadata.rs:562). Two measurements:child_work(lifecycle.rs:381-387) is a detached child spawned on nearly every command. It loads its map at startup and then runs a fullgit fetch --allbounded only byBACKGROUND_FETCH_TIMEOUT= 300s (repo_manager.rs:82) before writinglast_fetchedback from that pre-fetch copy. Two dl processes writing metadata at once is the designed steady state here, not an edge case —domain/locks.rs:4-10says so in as many words.--reconcile. The plan is built atdl/src/commands.rs:997and applied at:1020, withconfirmed("Re-point these? [y/N] ")sitting at:1012in between. The read-to-write window contains a human prompt, so it is unbounded.Reachable, then, and for minutes.
4. And yet nothing is lost through it, because there is no field to lose
I enumerated every field a second process can move while one of the two sites holds its stale copy.
BaseRepository, at the sweeper's write. The sweeper holds the repo lock, and every writer ofremote_url,local_pathanddefault_branchholds it too — all of them reach storage throughrecord(repo_manager.rs:1291), fromclone_if_missing/register_existing_bare, which areRepoLock-gated. The one field a non-holder can move isworktrees, pushed byadd_worktree(metadata.rs:508-510) and popped byremove_worktree(:532-534) on the reloaded record — andremove_clone(workspace_clone.rs:1278, therm/--prunepath) takes no repo lock, so it genuinely races the sweeper. ButBaseRepository.worktreesis written by dl and read by nothing. The only non-test mentions in the tree are those two mutators and serde (model.rs:274). It survives because the on-disk shape is pinned against what Python wrote, and Python is gone — nothing outsidetest/is a.pyfile any more.WorktreeInfo, atapply_reconciliation's write. The revertible fields arelocal_path,workspace_id,devpod_workspace_id,created_at,last_used.created_atandlast_usedhave no production reader either:dl lstakeslast_usedoff the devpod listing (clients/devpod.rs:726→listing.rs:883,:1114), never off the record. The other three are derived deterministically from(owner, repo, branch)throughWorkspaceId, so the only concurrent writer of that key (workspace_clone.rs:960) writes byte-identical values.That is the answer. The mechanism the finding describes is real; the "different field of the same record" it needs does not exist today. (b): the window is reachable, the bug is not.
5. What is not nothing
Two residuals through the same seam. Both real; neither prices a fix on its own.
add_worktree'sinsert(metadata.rs:506) is unconditional, so it resurrects a record a concurrent writer deleted. Plan a--reconcile, leave the prompt sitting,dl <ws> rma workspace in that plan from another terminal, answer y — the record comes back naming a directory that is gone. No data is lost: the filesystem wins by design (repo_manager.rs:1572-1576) and the next--pruneclears the row. But it is a lost delete, andupdate_worktree(key, |w| …)is a no-op on an absent key rather than a resurrection.--reconcilecould revertlocal_pathovermigrate_cache's rewrite, and that one is observable, sincermfollowslocal_path. It is also once per machine, on a pre-Build: parse workspace ids at one boundary (injective scheme) #64 cache, against a--reconcilerunning at that instant.6. Change the interface anyway
The safety of
repo_manager.rs:1431rests on an argument I had to reassemble out of six files: which lock the sweeper happens to hold, which callers happen to beRepoLock-gated, and which fields happen to have no reader. None of it is written down at the call site and none of it is pinned by a test —a_mutation_reloads_under_the_lock_rather_than_rewriting_a_stale_copy(metadata.rs:2443) pins the neighbouring-records half and stops there. The day somebody readsworktrees, or adds a field toBaseRepositorywritten from a path that takes no repo lock, the bug goes live and nothing goes red. That is exactly the reader-in-a-year cost principle 1 is about, and it is why "no live bug" is not the end of this ticket.The interface is what makes it possible.
add_worktree(WorktreeInfo)(metadata.rs:498) takes a whole record, so a caller with one field to change has no move but read-modify-write across the seam. The four call sites did not choose badly; there was nothing else to choose.Principle 2 agrees rather than objects, because the change is a deletion. Two call sites lose their
.cloned()+ field-set +add_*and become one closure each.add_*stays — wholesale registration (workspace_clone.rs:960) is a genuinely different intent from moving a field, andaddbesideupdateis the sum of the two, not an extra verb. With the flows layer no longer constructing records it never read, the fields onBaseRepository/WorktreeInfo(model.rs:199-223) can drop to module-private, which is principle 3: "a record whose unseen fields the caller invented" stops being representable.7. How it gets tested, and not the way "Proving it" says
The ticket proposes two processes, one worktree key, one field each. That test passes today — it has to, because §4 is the finding that no such pair of fields exists. Writing it would prove nothing and would leave the next reader believing a race was closed that was never open.
The behaviour genuinely absent needs no second process:
domain/locks.rs:14-17notes flock is per open file description, so a secondMetadataStorageon the same path inside one test is a real second writer.load()and itsupdate_*survives the update, and only the closure's field moves. Red today underadd_*, because the pre-lock clone overwrites it.update_worktreeon a key another writer has deleted writes nothing and reports so, rather than resurrecting the row. Red today atmetadata.rs:506.Graduating as a maintainability-and-modelling slice, explicitly not a bug fix, so nobody reads the resulting PR as having closed a live race.
blooop commentedon Aug 24, 2026
Graduated as #412 on the map —
update_repository/update_worktreeas closures over the record loaded under the lock, the two genuine call sites converted, the other two left alone with the reason written down, and the record fields narrowed. Filed as maintainability and modelling, not as a bug fix: §4 above is why there is nothing to fix, and #412 carries the two seam tests that actually go red instead of the two-process one proposed here.