Hold the working-copy lock through every working-copy write 6caacfff1d4f
A status run while a bookmark switch rewrote the working copy (on metalgear, most likely the editor extension reacting to the files being written) read the half-written files, recorded them as edits of the old base, and its view then replaced the switch's in the merge of the two operations. Status showed the switch's rewrite as the user's changes and the switch back was refused. Only snapshots took the workspace lock. Checkout, bookmark switch and --carry, merge, commit, amend and rewrites, discard and pull now hold it from reading the head until their operation is recorded; it is re-entrant within a thread, waits up to a minute, and a live process's lock is never taken over however old. A workspace changed by two concurrent operations is now an error instead of the later one winning. A test races a snapshot thread against twenty switches. Spec Draft 0.8r. closes #36
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 21:19 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
6caacfff1d4f62ca230a4dd2d34ffe47afb321e829461bfab48ffeadaff5e758- Depends on
- 132b884b9a8d 19555025a2ac 1c2bd9abdd4f 23b217d75848 4e88e774777a 614541c3cebd 61e3bce7ea5b 6f8aeb996558 75167672ebcd 7b9809e67c3d 8ab265dc4a51 982864068a59 c07e0e7d2d85 c6aa5bd9d1c8 ff0af1ee299d
- Depended on by
- d36ba2f79f95 767ac6cd0545 e59512f2f075 530a0fc650a2 7505194116e7 b30a880fa409 e226a6cc2f71 98a1620a3840 14edbaaf5291 e266b7d4065c f9466021286a 192fad1e46a2 d3cd7ef95da6 c5ce0367eddf 1a3b62032ed9 4fe7d6045860
- In bookmarks
- agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 38 atoms, 0 tree operations, 9 files
Browse the tree at this change
modified crates/oxy-core/src/repo/bookmarks.rs +4 −0
| … | 159 unchanged lines |
||
| 160 | 160 | ||
| 161 | 161 | /// Make `name` this workspace's current bookmark and check it out. |
|
| 162 | 162 | pub fn bookmark_switch(&mut self, name: &str, discard: bool) -> Result<CheckoutReport> { |
|
| 163 | + | let _wc = self.lock_working_copy()?; |
|
| 163 | 164 | let view = self.view()?; |
|
| 164 | 165 | let Some(st) = view.bookmarks.get(name) else { |
|
| 165 | 166 | return fail(format!("no bookmark {name}")); |
|
| … | 10 unchanged lines |
||
| 176 | 177 | /// switch` does: see [`Repo::checkout_carry`]. Returns the checkout |
|
| 177 | 178 | /// report and the carried paths. |
|
| 178 | 179 | pub fn bookmark_switch_carry(&mut self, name: &str) -> Result<(CheckoutReport, Vec<String>)> { |
|
| 180 | + | let _wc = self.lock_working_copy()?; |
|
| 179 | 181 | let view = self.view()?; |
|
| 180 | 182 | let Some(st) = view.bookmarks.get(name) else { |
|
| 181 | 183 | return fail(format!("no bookmark {name}")); |
|
| … | 20 unchanged lines |
||
| 202 | 204 | target: &str, |
|
| 203 | 205 | command: &str, |
|
| 204 | 206 | ) -> Result<(CheckoutReport, Vec<String>)> { |
|
| 207 | + | let _wc = self.lock_working_copy()?; |
|
| 205 | 208 | let snap = self.snapshot(command)?; |
|
| 206 | 209 | // Every path the work touches: changed ones, and base files it |
|
| 207 | 210 | // removed or renamed away (a rename reports only its new path). |
|
| … | 67 unchanged lines |
||
| 275 | 278 | /// Union the heads of bookmark `name` into the current bookmark (spec |
|
| 276 | 279 | /// §10.1). A working copy without changes follows. |
|
| 277 | 280 | pub fn merge(&self, name: &str) -> Result<MergeReport> { |
|
| 281 | + | let _wc = self.lock_working_copy()?; |
|
| 278 | 282 | let (op_ref, op) = self.head_op()?; |
|
| 279 | 283 | let current = self.config.bookmark.clone(); |
|
| 280 | 284 | if name == current { |
|
| … | 52 unchanged lines |
modified crates/oxy-core/src/repo/checkout.rs +1 −0
| … | 20 unchanged lines |
||
| 21 | 21 | discard: bool, |
|
| 22 | 22 | command: &str, |
|
| 23 | 23 | ) -> Result<CheckoutReport> { |
|
| 24 | + | let _wc = self.lock_working_copy()?; |
|
| 24 | 25 | let snap = self.snapshot(command)?; |
|
| 25 | 26 | if !discard && (!snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty()) { |
|
| 26 | 27 | let list: Vec<String> = snap |
|
| … | 243 unchanged lines |
modified crates/oxy-core/src/repo/history.rs +3 −0
| … | 34 unchanged lines |
||
| 35 | 35 | new: Option<&Change>, |
|
| 36 | 36 | command: &str, |
|
| 37 | 37 | ) -> Result<Option<ChangeHash>> { |
|
| 38 | + | let _wc = self.lock_working_copy()?; |
|
| 38 | 39 | let new_hash = match new { |
|
| 39 | 40 | Some(c) => Some(self.store_signed(c)?), |
|
| 40 | 41 | None => None, |
|
| … | 60 unchanged lines |
||
| 101 | 102 | corr: &Correspondence, |
|
| 102 | 103 | command: &str, |
|
| 103 | 104 | ) -> Result<(Option<ChangeHash>, Vec<String>)> { |
|
| 105 | + | let _wc = self.lock_working_copy()?; |
|
| 104 | 106 | let (op_ref, op) = self.head_op()?; |
|
| 105 | 107 | let bookmark = self.config.bookmark.clone(); |
|
| 106 | 108 | let heads = self.heads_of(&op.view, &bookmark)?; |
|
| … | 210 unchanged lines |
||
| 317 | 319 | /// rewritten with an identity correspondence, since the target's own |
|
| 318 | 320 | /// atoms keep their indices. |
|
| 319 | 321 | pub fn amend_into(&self, target: &ChangeHash, message: Option<&str>) -> Result<ChangeHash> { |
|
| 322 | + | let _wc = self.lock_working_copy()?; |
|
| 320 | 323 | let snap = self.snapshot("oxy amend")?; |
|
| 321 | 324 | if snap |
|
| 322 | 325 | .statuses |
|
| … | 880 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +15 −4
| … | 103 unchanged lines |
||
| 104 | 104 | pub use subrepos::{SubrepoInfo, SubrepoSync}; |
|
| 105 | 105 | pub use sync::{FetchReport, PullReport, PushReport, RefUpdated}; |
|
| 106 | 106 | pub use tags::TagInfo; |
|
| 107 | - | pub use workspace::{FileLock, SnapshotLock, WorkspaceInfo}; |
|
| 107 | + | pub use workspace::{FileLock, SnapshotLock, WorkingCopyLock, WorkspaceInfo}; |
|
| 108 | 108 | ||
| 109 | 109 | /// The file name under which per-bookmark or per-tag data is kept: `/` |
|
| 110 | 110 | /// (and `%`) escaped, so `feature/x` is one file beside `feature` rather |
|
| … | 538 unchanged lines |
||
| 649 | 649 | /// Three-way merge of two views that both started from `base` (spec |
|
| 650 | 650 | /// §12.1). A reference only one side changed takes that side's value. |
|
| 651 | 651 | /// When both changed it differently: a bookmark keeps both sides' |
|
| 652 | - | /// heads (it may then have several), a change beats a deletion, and |
|
| 653 | - | /// otherwise this process's value wins for workspaces, remote |
|
| 652 | + | /// heads (it may then have several), a change beats a deletion, a |
|
| 653 | + | /// workspace changed by both is an error (only one of them matches the |
|
| 654 | + | /// files on disk), and otherwise this process's value wins for remote |
|
| 654 | 655 | /// references and the policy, the first value for a tag (tags never |
|
| 655 | 656 | /// move). |
|
| 656 | 657 | fn merge_views(&self, base: &View, ours: &View, theirs: &View) -> Result<View> { |
|
| … | 35 unchanged lines |
||
| 692 | 693 | Ok(self.store.put_state(&StateObject::new(heads))?) |
|
| 693 | 694 | }, |
|
| 694 | 695 | )?; |
|
| 696 | + | // Two operations that both replaced one workspace's working copy |
|
| 697 | + | // cannot both be right: the files on disk match only one of them. |
|
| 698 | + | // The working-copy lock keeps this from happening; should it happen |
|
| 699 | + | // anyway, this operation is not recorded. |
|
| 695 | 700 | let workspaces = pick( |
|
| 696 | 701 | &base.workspaces, |
|
| 697 | 702 | &ours.workspaces, |
|
| 698 | 703 | &theirs.workspaces, |
|
| 699 | - | |x, _| Ok(*x), |
|
| 704 | + | |_, _| { |
|
| 705 | + | fail( |
|
| 706 | + | "another oxy process changed this working copy at the same time; \ |
|
| 707 | + | this command was not recorded: check `oxy status` and run it again", |
|
| 708 | + | ) |
|
| 709 | + | }, |
|
| 700 | 710 | )?; |
|
| 701 | 711 | let tags = pick(&base.tags, &ours.tags, &theirs.tags, |_, y| Ok(*y))?; |
|
| 702 | 712 | let empty = BTreeMap::new(); |
|
| … | 53 unchanged lines |
||
| 756 | 766 | /// a working-copy snapshot are skipped, since the files are still on disk |
|
| 757 | 767 | /// and the next snapshot recreates them. |
|
| 758 | 768 | pub fn undo(&self) -> Result<Op> { |
|
| 769 | + | let _wc = self.lock_working_copy()?; |
|
| 759 | 770 | let (read_head, mut op) = self.head_op()?; |
|
| 760 | 771 | while op.is_snapshot() { |
|
| 761 | 772 | let Some(parent) = op.parents.first() else { |
|
| … | 875 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +3 −0
| … | 542 unchanged lines |
||
| 543 | 543 | extra_deps: &[ChangeHash], |
|
| 544 | 544 | selection: &Selection, |
|
| 545 | 545 | ) -> Result<ChangeHash> { |
|
| 546 | + | let _wc = self.lock_working_copy()?; |
|
| 546 | 547 | let snap = self.snapshot_status("oxy commit")?; |
|
| 547 | 548 | if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() { |
|
| 548 | 549 | return fail("nothing to commit: the working copy matches its base"); |
|
| … | 510 unchanged lines |
||
| 1059 | 1060 | /// edits are undone, additions removed, removals put back. Returns the |
|
| 1060 | 1061 | /// number of paths touched. |
|
| 1061 | 1062 | pub fn discard(&self, paths: &[String]) -> Result<usize> { |
|
| 1063 | + | let _wc = self.lock_working_copy()?; |
|
| 1062 | 1064 | let snap = self.snapshot_status("oxy discard")?; |
|
| 1063 | 1065 | let under = |p: &str| { |
|
| 1064 | 1066 | paths |
|
| … | 79 unchanged lines |
||
| 1144 | 1146 | /// disk, keeping the others: the files are rewritten from the base plus |
|
| 1145 | 1147 | /// the remaining hunks. |
|
| 1146 | 1148 | pub fn discard_hunks(&self, indices: &[usize]) -> Result<usize> { |
|
| 1149 | + | let _wc = self.lock_working_copy()?; |
|
| 1147 | 1150 | let (snap, hunks) = self.hunks()?; |
|
| 1148 | 1151 | let keep: Vec<usize> = hunks |
|
| 1149 | 1152 | .iter() |
|
| … | 1160 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +1 −0
| … | 1246 unchanged lines |
||
| 1247 | 1247 | /// Fetch, then take the union of the local bookmark's state with the |
|
| 1248 | 1248 | /// remote's: no merge change, conflicts become reified (spec §9.4). |
|
| 1249 | 1249 | pub fn pull(&self, remote_name: &str) -> Result<PullReport> { |
|
| 1250 | + | let _wc = self.lock_working_copy()?; |
|
| 1250 | 1251 | self.fetch(remote_name)?; |
|
| 1251 | 1252 | let (op_ref, op) = self.head_op()?; |
|
| 1252 | 1253 | let bookmark = self.config.bookmark.clone(); |
|
| … | 392 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +92 −6
| … | 2192 unchanged lines |
||
| 2193 | 2193 | "a forgotten workspace is no longer in the view" |
|
| 2194 | 2194 | ); |
|
| 2195 | 2195 | ||
| 2196 | - | // The snapshot lock: a fresh lock blocks a second, a stale one is taken over. |
|
| 2196 | + | // The working-copy lock: re-entrant in a thread; a lock whose process |
|
| 2197 | + | // is gone is taken over, and a live process's is not, however old. |
|
| 2197 | 2198 | let lock = repo.lock_snapshot().unwrap(); |
|
| 2199 | + | let again = repo.lock_working_copy().unwrap(); |
|
| 2200 | + | drop(again); |
|
| 2198 | 2201 | drop(lock); |
|
| 2199 | 2202 | let lock_path = main_dir |
|
| 2200 | 2203 | .join(".oxy/workspaces") |
|
| 2201 | 2204 | .join(repo.workspace().to_hex()) |
|
| 2202 | 2205 | .join("lock"); |
|
| 2203 | - | fs::write(&lock_path, "1").unwrap(); |
|
| 2204 | 2206 | let old = std::time::SystemTime::now() - std::time::Duration::from_secs(120); |
|
| 2205 | - | fs::File::open(&lock_path) |
|
| 2206 | - | .unwrap() |
|
| 2207 | - | .set_modified(old) |
|
| 2208 | - | .unwrap(); |
|
| 2207 | + | let age = |p: &Path| fs::File::open(p).unwrap().set_modified(old).unwrap(); |
|
| 2208 | + | fs::write(&lock_path, std::process::id().to_string()).unwrap(); |
|
| 2209 | + | age(&lock_path); |
|
| 2210 | + | assert!( |
|
| 2211 | + | !super::workspace::lock_is_stale(&lock_path), |
|
| 2212 | + | "a live process keeps its lock" |
|
| 2213 | + | ); |
|
| 2214 | + | fs::write(&lock_path, "4294967295").unwrap(); |
|
| 2215 | + | age(&lock_path); |
|
| 2209 | 2216 | assert!(repo.lock_snapshot().is_ok(), "stale lock taken over"); |
|
| 2210 | 2217 | } |
|
| 2211 | 2218 | ||
| … | 3355 unchanged lines |
||
| 5567 | 5574 | let c = Repo::clone(&root.join("a").to_string_lossy(), &root.join("c")).unwrap(); |
|
| 5568 | 5575 | assert_eq!(fs::read(root.join("c/big.txt")).unwrap(), edited.as_bytes()); |
|
| 5569 | 5576 | drop(c); |
|
| 5577 | + | } |
|
| 5578 | + | ||
| 5579 | + | #[test] |
|
| 5580 | + | fn operations_that_both_replace_a_working_copy_are_not_merged() { |
|
| 5581 | + | hermetic(); |
|
| 5582 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 5583 | + | let root = dir.path(); |
|
| 5584 | + | let repo = Repo::init(root).unwrap(); |
|
| 5585 | + | write(root, "a.txt", b"a\n"); |
|
| 5586 | + | let a = repo.commit("a", &[]).unwrap(); |
|
| 5587 | + | write(root, "b.txt", b"b\n"); |
|
| 5588 | + | repo.commit("b", &[]).unwrap(); |
|
| 5589 | + | let (x, op) = repo.head_op().unwrap(); |
|
| 5590 | + | let ws = *op.view.workspaces.keys().next().unwrap(); |
|
| 5591 | + | let mut one = op.view.clone(); |
|
| 5592 | + | one.workspaces.insert(ws, a); |
|
| 5593 | + | let mut two = op.view.clone(); |
|
| 5594 | + | two.workspaces.insert(ws, ObjectRef([7; 32])); |
|
| 5595 | + | let first = repo.record_op(&[x], "one", one).unwrap(); |
|
| 5596 | + | let e = repo.record_op(&[x], "two", two).unwrap_err(); |
|
| 5597 | + | assert!( |
|
| 5598 | + | e.to_string() |
|
| 5599 | + | .contains("changed this working copy at the same time"), |
|
| 5600 | + | "{e}" |
|
| 5601 | + | ); |
|
| 5602 | + | assert_eq!( |
|
| 5603 | + | repo.head_op().unwrap().0, |
|
| 5604 | + | first, |
|
| 5605 | + | "the second is not recorded" |
|
| 5606 | + | ); |
|
| 5607 | + | } |
|
| 5608 | + | ||
| 5609 | + | /// Metalgear, 2026-09-29 (oxygen#36): a status run while a bookmark switch |
|
| 5610 | + | /// rewrote the working copy recorded the half-written files as edits, and |
|
| 5611 | + | /// its view replaced the switch's. |
|
| 5612 | + | #[test] |
|
| 5613 | + | fn a_snapshot_during_a_switch_never_records_the_switch_as_edits() { |
|
| 5614 | + | hermetic(); |
|
| 5615 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 5616 | + | let root = dir.path().to_path_buf(); |
|
| 5617 | + | let mut repo = Repo::init(&root).unwrap(); |
|
| 5618 | + | write(&root, "keep.txt", b"keep\n"); |
|
| 5619 | + | repo.commit("base", &[]).unwrap(); |
|
| 5620 | + | repo.bookmark_create("small", None).unwrap(); |
|
| 5621 | + | for i in 0..300 { |
|
| 5622 | + | write( |
|
| 5623 | + | &root, |
|
| 5624 | + | &format!("many/f{i}.txt"), |
|
| 5625 | + | format!("file {i}\n").as_bytes(), |
|
| 5626 | + | ); |
|
| 5627 | + | } |
|
| 5628 | + | repo.commit("many", &[]).unwrap(); |
|
| 5629 | + | let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); |
|
| 5630 | + | let watcher = { |
|
| 5631 | + | let (root, stop) = (root.clone(), stop.clone()); |
|
| 5632 | + | std::thread::spawn(move || { |
|
| 5633 | + | let r = Repo::open(&root).unwrap(); |
|
| 5634 | + | let mut ran = 0; |
|
| 5635 | + | while !stop.load(std::sync::atomic::Ordering::Relaxed) { |
|
| 5636 | + | if r.snapshot_status("oxy status").is_ok() { |
|
| 5637 | + | ran += 1; |
|
| 5638 | + | } |
|
| 5639 | + | } |
|
| 5640 | + | ran |
|
| 5641 | + | }) |
|
| 5642 | + | }; |
|
| 5643 | + | for i in 0..20 { |
|
| 5644 | + | let to = if i % 2 == 0 { "small" } else { "main" }; |
|
| 5645 | + | repo.bookmark_switch(to, false).unwrap(); |
|
| 5646 | + | } |
|
| 5647 | + | stop.store(true, std::sync::atomic::Ordering::Relaxed); |
|
| 5648 | + | assert!(watcher.join().unwrap() > 0, "the watcher snapshotted"); |
|
| 5649 | + | // Back on main, clean, with every file where main has it. |
|
| 5650 | + | let repo = Repo::open(&root).unwrap(); |
|
| 5651 | + | let s = repo.snapshot_status("oxy status").unwrap(); |
|
| 5652 | + | assert!(s.statuses.is_empty(), "{:?}", statuses(&s)); |
|
| 5653 | + | assert!(s.wc.edits.is_empty() && s.wc.tree_ops.is_empty()); |
|
| 5654 | + | assert_eq!(s.wc.deps, repo.bookmark_heads().unwrap()); |
|
| 5655 | + | assert!(root.join("many/f299.txt").exists()); |
|
| 5570 | 5656 | } |
modified crates/oxy-core/src/repo/workspace.rs +105 −17
| … | 14 unchanged lines |
||
| 15 | 15 | pub current: bool, |
|
| 16 | 16 | } |
|
| 17 | 17 | ||
| 18 | - | /// A short-lived lock file, taken by creating it exclusively and |
|
| 19 | - | /// released on drop: a workspace's snapshot lock, the operation head's |
|
| 20 | - | /// lock. A stale one (older than a minute, its process gone) is taken |
|
| 21 | - | /// over. |
|
| 18 | + | /// A lock file, taken by creating it exclusively and released on drop: a |
|
| 19 | + | /// workspace's working-copy lock, the operation head's lock. A stale one, |
|
| 20 | + | /// whose process is gone (or, where that cannot be told, older than a |
|
| 21 | + | /// minute), is taken over. |
|
| 22 | 22 | pub struct FileLock { |
|
| 23 | 23 | path: PathBuf, |
|
| 24 | 24 | } |
|
| 25 | 25 | ||
| 26 | - | /// Held while a workspace is snapshotted. |
|
| 27 | - | pub type SnapshotLock = FileLock; |
|
| 26 | + | /// A workspace's working-copy lock (spec §12.1), held by everything that |
|
| 27 | + | /// reads or writes the working copy and records its working-copy change: |
|
| 28 | + | /// snapshot, checkout, bookmark switch, commit, discard, pull. It is |
|
| 29 | + | /// re-entrant within a thread, so a checkout that snapshots first holds it |
|
| 30 | + | /// once. |
|
| 31 | + | pub struct WorkingCopyLock { |
|
| 32 | + | path: PathBuf, |
|
| 33 | + | _file: Option<FileLock>, |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | /// The working-copy lock; its old name. |
|
| 37 | + | pub type SnapshotLock = WorkingCopyLock; |
|
| 28 | 38 | ||
| 39 | + | thread_local! { |
|
| 40 | + | /// Working-copy locks this thread holds, with their depth. |
|
| 41 | + | static HELD: std::cell::RefCell<HashMap<PathBuf, usize>> = Default::default(); |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | impl Drop for WorkingCopyLock { |
|
| 45 | + | fn drop(&mut self) { |
|
| 46 | + | HELD.with(|h| { |
|
| 47 | + | let mut h = h.borrow_mut(); |
|
| 48 | + | if let Some(n) = h.get_mut(&self.path) { |
|
| 49 | + | *n -= 1; |
|
| 50 | + | if *n == 0 { |
|
| 51 | + | h.remove(&self.path); |
|
| 52 | + | } |
|
| 53 | + | } |
|
| 54 | + | }); |
|
| 55 | + | } |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | /// Whether the process that wrote a lock file is gone. Where that cannot |
|
| 59 | + | /// be told (no `/proc`), a lock older than a minute counts as stale. |
|
| 60 | + | pub(crate) fn lock_is_stale(path: &Path) -> bool { |
|
| 61 | + | let old = fs::metadata(path) |
|
| 62 | + | .and_then(|m| m.modified()) |
|
| 63 | + | .map(|t| t.elapsed().map(|d| d.as_secs() > 60).unwrap_or(false)) |
|
| 64 | + | .unwrap_or(true); |
|
| 65 | + | let proc_fs = Path::new("/proc/self").exists(); |
|
| 66 | + | match fs::read_to_string(path) |
|
| 67 | + | .ok() |
|
| 68 | + | .and_then(|s| s.trim().parse::<u32>().ok()) |
|
| 69 | + | { |
|
| 70 | + | Some(pid) if proc_fs => !Path::new("/proc").join(pid.to_string()).exists(), |
|
| 71 | + | // Being written this instant, or by something that is not oxy. |
|
| 72 | + | _ => old, |
|
| 73 | + | } |
|
| 74 | + | } |
|
| 75 | + | ||
| 29 | 76 | impl Drop for FileLock { |
|
| 30 | 77 | fn drop(&mut self) { |
|
| 31 | 78 | let _ = fs::remove_file(&self.path); |
|
| … | 3 unchanged lines |
||
| 35 | 82 | impl FileLock { |
|
| 36 | 83 | /// Wait up to about five seconds for `path`; `busy` is the error after that. |
|
| 37 | 84 | pub(crate) fn acquire(path: PathBuf, busy: &str) -> Result<FileLock> { |
|
| 38 | - | for _ in 0..500 { |
|
| 85 | + | Self::acquire_within(path, busy, std::time::Duration::from_secs(5)) |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | /// Wait up to `wait` for `path`; `busy` is the error after that. |
|
| 89 | + | pub(crate) fn acquire_within( |
|
| 90 | + | path: PathBuf, |
|
| 91 | + | busy: &str, |
|
| 92 | + | wait: std::time::Duration, |
|
| 93 | + | ) -> Result<FileLock> { |
|
| 94 | + | let until = std::time::Instant::now() + wait; |
|
| 95 | + | loop { |
|
| 39 | 96 | match fs::OpenOptions::new() |
|
| 40 | 97 | .write(true) |
|
| 41 | 98 | .create_new(true) |
|
| … | 4 unchanged lines |
||
| 46 | 103 | return Ok(FileLock { path }); |
|
| 47 | 104 | } |
|
| 48 | 105 | Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { |
|
| 49 | - | let stale = fs::metadata(&path) |
|
| 50 | - | .and_then(|m| m.modified()) |
|
| 51 | - | .map(|t| t.elapsed().map(|d| d.as_secs() > 60).unwrap_or(false)) |
|
| 52 | - | .unwrap_or(true); |
|
| 53 | - | if stale { |
|
| 106 | + | if lock_is_stale(&path) { |
|
| 54 | 107 | let _ = fs::remove_file(&path); |
|
| 55 | 108 | continue; |
|
| 56 | 109 | } |
|
| 110 | + | if std::time::Instant::now() >= until { |
|
| 111 | + | return fail(busy); |
|
| 112 | + | } |
|
| 57 | 113 | std::thread::sleep(std::time::Duration::from_millis(10)); |
|
| 58 | 114 | } |
|
| 59 | 115 | Err(e) => return Err(e.into()), |
|
| 60 | 116 | } |
|
| 61 | 117 | } |
|
| 62 | - | fail(busy) |
|
| 63 | 118 | } |
|
| 64 | 119 | } |
|
| 65 | 120 | ||
| … | 124 unchanged lines |
||
| 190 | 245 | ||
| 191 | 246 | /// Take this workspace's snapshot lock (spec §12.1). |
|
| 192 | 247 | pub(crate) fn lock_snapshot(&self) -> Result<SnapshotLock> { |
|
| 248 | + | self.lock_working_copy() |
|
| 249 | + | } |
|
| 250 | + | ||
| 251 | + | /// Take this workspace's working-copy lock (spec §12.1), waiting up to |
|
| 252 | + | /// a minute for another process's checkout or snapshot to finish. |
|
| 253 | + | pub fn lock_working_copy(&self) -> Result<WorkingCopyLock> { |
|
| 193 | 254 | let dir = self.workspace_dir(&self.workspace); |
|
| 255 | + | if self.bare { |
|
| 256 | + | // No working copy to guard. |
|
| 257 | + | return Ok(WorkingCopyLock { |
|
| 258 | + | path: dir, |
|
| 259 | + | _file: None, |
|
| 260 | + | }); |
|
| 261 | + | } |
|
| 194 | 262 | fs::create_dir_all(&dir)?; |
|
| 195 | - | FileLock::acquire( |
|
| 196 | - | dir.join("lock"), |
|
| 197 | - | "another oxy process is snapshotting this workspace", |
|
| 198 | - | ) |
|
| 263 | + | let path = dir.join("lock"); |
|
| 264 | + | let held = HELD.with(|h| { |
|
| 265 | + | let mut h = h.borrow_mut(); |
|
| 266 | + | match h.get_mut(&path) { |
|
| 267 | + | Some(n) => { |
|
| 268 | + | *n += 1; |
|
| 269 | + | true |
|
| 270 | + | } |
|
| 271 | + | None => false, |
|
| 272 | + | } |
|
| 273 | + | }); |
|
| 274 | + | if held { |
|
| 275 | + | return Ok(WorkingCopyLock { path, _file: None }); |
|
| 276 | + | } |
|
| 277 | + | let file = FileLock::acquire_within( |
|
| 278 | + | path.clone(), |
|
| 279 | + | "another oxy process is using this working copy (a checkout or snapshot); try again when it finishes", |
|
| 280 | + | std::time::Duration::from_secs(60), |
|
| 281 | + | )?; |
|
| 282 | + | HELD.with(|h| h.borrow_mut().insert(path.clone(), 1)); |
|
| 283 | + | Ok(WorkingCopyLock { |
|
| 284 | + | path, |
|
| 285 | + | _file: Some(file), |
|
| 286 | + | }) |
|
| 199 | 287 | } |
|
| 200 | 288 | } |
modified docs/spec.md +4 −4
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.8q | |
|
| 5 | + | | **Status** | Draft 0.8r | |
|
| 6 | 6 | | **Format version** | 1 (changes: 2) | |
|
| 7 | 7 | | **Date** | 2026-09-29 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 467 unchanged lines |
||
| 476 | 476 | } |
|
| 477 | 477 | ``` |
|
| 478 | 478 | ||
| 479 | - | `view` is the complete state of all references and workspaces after the operation. The operation log is a DAG. The head (`.oxy/ops/head`) moves by compare-and-swap under a short lock (`.oxy/ops/lock`), held only while the pointer is written: a process that finds the head is no longer the parent it built on records a merge operation with both as parents, whose view is the three-way merge of the two views against that parent. A reference changed on one side takes that side's value; changed differently on both, a bookmark keeps both sides' heads (it then has several), a change beats a deletion, a tag keeps its first value, and workspaces, remote references and the policy take the later process's value. `oxy undo` refuses when the head moved while it ran. |
|
| 479 | + | `view` is the complete state of all references and workspaces after the operation. The operation log is a DAG. The head (`.oxy/ops/head`) moves by compare-and-swap under a short lock (`.oxy/ops/lock`), held only while the pointer is written: a process that finds the head is no longer the parent it built on records a merge operation with both as parents, whose view is the three-way merge of the two views against that parent. A reference changed on one side takes that side's value; changed differently on both, a bookmark keeps both sides' heads (it then has several), a change beats a deletion, a tag keeps its first value, remote references and the policy take the later process's value, and a workspace changed by both is an error: the later operation is not recorded, since the files on disk match only one of the two working-copy changes. `oxy undo` refuses when the head moved while it ran. |
|
| 480 | 480 | ||
| 481 | 481 | `oxy undo` moves the current operation to its parent's view. `oxy redo` and `oxy op log` expose the DAG. Operations are retained for `core.op-retention` (default 90 days) and then eligible for garbage collection. |
|
| 482 | 482 | ||
| 483 | - | Because all mutation goes through the operation log and object writes are idempotent, no long-held repository lock is needed: the operation head's lock covers one pointer write, and a workspace's snapshot lock covers one snapshot of that workspace. |
|
| 483 | + | Because all mutation goes through the operation log and object writes are idempotent, no long-held repository lock is needed: the operation head's lock covers one pointer write. Each workspace has a **working-copy lock** (`.oxy/workspaces/<id>/lock`), held by every command that reads or writes the working copy and replaces its working-copy change (snapshot, checkout, bookmark switch, merge, commit, amend and rewrites, discard, pull), from reading the operation head until its operation is recorded. A snapshot therefore never sees a half-written checkout, and never records one as edits. The lock is re-entrant within a process thread; a waiting command gives up after a minute and says why; a lock whose process is gone is taken over, and where that cannot be told, one older than a minute. |
|
| 484 | 484 | ||
| 485 | 485 | ### 12.2 Workspaces |
|
| 486 | 486 | ||
| … | 487 unchanged lines |
||
| 974 | 974 | ||
| 975 | 975 | --- |
|
| 976 | 976 | ||
| 977 | - | *Changelog.* Draft 0.8q (2026-09-29): change format version 2 stores large inserts as text blobs, so a change does not grow with the files it adds (§6.1, §7.4); no object may exceed 1 GiB (§13.2). Draft 0.8p (2026-09-29): Git import derives new NodeIds from the commit, making imports deterministic (§23). Draft 0.8o (2026-09-29): execution receipts, the `receipt` object and attestation (§14.6). Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11). |
|
| 977 | + | *Changelog.* Draft 0.8r (2026-09-29): the working-copy lock covers every command that writes the working copy; a workspace changed by two concurrent operations is an error (§12.1). Draft 0.8q (2026-09-29): change format version 2 stores large inserts as text blobs, so a change does not grow with the files it adds (§6.1, §7.4); no object may exceed 1 GiB (§13.2). Draft 0.8p (2026-09-29): Git import derives new NodeIds from the commit, making imports deterministic (§23). Draft 0.8o (2026-09-29): execution receipts, the `receipt` object and attestation (§14.6). Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11). |
|
| 978 | 978 | ||
| 979 | 979 | Draft 0.8i (2026-09-28): portability rules for exec bits and path names (§7.7); found by verifying a real Git import commit by commit: deletions depend on the inserters of the lines right after them, and replacements on the change that set their old blob (§8.1); replace records are superseded causally, so a reverted binary keeps its blob (§9.3); merges resolve opaque conflicts to the commit's blob, and `oxy git verify` (§23). Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1); `oxy doctor` (§21.2). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 980 | 980 | ||
| … | 61 unchanged lines |