|
1 | 1 | use but_core::sync::{LockScope, try_exclusive_inter_process_access}; |
2 | 2 | use but_testsupport::gix_testtools; |
| 3 | +use std::time::Duration; |
3 | 4 |
|
4 | 5 | #[test] |
5 | 6 | fn exclusive_lock_prevents_second_acquisition() -> anyhow::Result<()> { |
@@ -103,3 +104,54 @@ fn default_lock_scope_is_all_operations() { |
103 | 104 | let default_scope = LockScope::default(); |
104 | 105 | assert!(matches!(default_scope, LockScope::AllOperations)); |
105 | 106 | } |
| 107 | + |
| 108 | +/// Regression test: contention on one repo must not block access to an unrelated repo. |
| 109 | +/// |
| 110 | +/// Before the fix, `shared_repo_access` / `exclusive_repo_access` held the global |
| 111 | +/// `WORKTREE_LOCKS` mutex while blocking on the per-repo RwLock. This meant a thread |
| 112 | +/// waiting for repo "A"'s RwLock would prevent any other thread from acquiring a lock |
| 113 | +/// on repo "B", turning per-repo contention into a process-wide bottleneck. |
| 114 | +#[test] |
| 115 | +fn repo_lock_contention_does_not_block_unrelated_repos() { |
| 116 | + // Thread 1: hold exclusive access to repo "A". |
| 117 | + let exclusive_a = but_core::sync::exclusive_repo_access("/test/repo-a"); |
| 118 | + |
| 119 | + // Thread 2: try to acquire shared access to repo "A". |
| 120 | + // This will block because thread 1 holds the exclusive lock. |
| 121 | + let (tx_ready, rx_ready) = std::sync::mpsc::channel(); |
| 122 | + let blocked_thread = std::thread::spawn(move || { |
| 123 | + // Signal that we're about to enter the lock acquisition path. |
| 124 | + tx_ready.send(()).unwrap(); |
| 125 | + // This blocks until exclusive_a is dropped. |
| 126 | + let _shared_a = but_core::sync::shared_repo_access("/test/repo-a"); |
| 127 | + }); |
| 128 | + |
| 129 | + // Wait until thread 2 has signalled it is about to call shared_repo_access. |
| 130 | + rx_ready.recv().unwrap(); |
| 131 | + // Yield repeatedly so the OS scheduler runs thread 2 into the blocking |
| 132 | + // lock path. Unlike a fixed sleep this adapts to scheduler pressure and |
| 133 | + // avoids a hard-coded timing assumption. |
| 134 | + for _ in 0..100 { |
| 135 | + std::thread::yield_now(); |
| 136 | + } |
| 137 | + |
| 138 | + // Thread 3 (this thread): access a completely unrelated repo "B". |
| 139 | + // With the bug, this would deadlock because thread 2 holds the global mutex |
| 140 | + // while waiting for repo A's RwLock. |
| 141 | + let (tx, rx) = std::sync::mpsc::channel(); |
| 142 | + let probe = std::thread::spawn(move || { |
| 143 | + let _shared_b = but_core::sync::shared_repo_access("/test/repo-b"); |
| 144 | + tx.send(()).ok(); |
| 145 | + }); |
| 146 | + |
| 147 | + // If repo "B" access completes within 2 seconds, the global mutex is not held |
| 148 | + // across RwLock acquisition — the bug is fixed. |
| 149 | + rx.recv_timeout(Duration::from_secs(2)) |
| 150 | + .expect("accessing an unrelated repo should not be blocked by contention on another repo"); |
| 151 | + probe.join().unwrap(); |
| 152 | + |
| 153 | + // Release the exclusive lock so thread 2 can complete, then join to |
| 154 | + // surface any panics that occurred inside it. |
| 155 | + drop(exclusive_a); |
| 156 | + blocked_thread.join().unwrap(); |
| 157 | +} |
0 commit comments