Finding
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.
Found by an architecture review.
Finding
MetadataStorageowns 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 callsself.load()under it — correct, and the module doc at:20-24says why. But the interface offersget_*andadd_*and no update, so every caller does read-modify-write across the seam: the record handed toadd_worktreewas cloned before the lock existed, andadd_worktree(:504) inserts it wholesale over the freshly reloaded map.Call sites:
flows/repo_manager.rs:1429-1436— clone, setlast_fetched, addflows/workspace_clone.rs:960-961— clone, setdevpod_workspace_id, addflows/lifecycle.rs:3068-3069— clone, setdevpod_workspace_id, addflows/migration.rs:306— clone, setlocal_path, addThe 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
dlruns writing the same worktree key concurrently, andserialize_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.
Found by an architecture review.