Skip to content

Commit edf8349

Browse files
authored
Merge pull request #13027 from gitbutlerapp/per-repo-locking
fix: release global mutex before blocking on per-repo lock
2 parents 50ade8d + 368f324 commit edf8349

2 files changed

Lines changed: 68 additions & 6 deletions

File tree

crates/but-core/src/sync.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,15 @@ pub fn try_exclusive_inter_process_access(
8181
/// Note that this **in-process** locking works only under the assumption that no two instances of
8282
/// GitButler are able to read or write the same repository.
8383
pub fn exclusive_repo_access(git_dir: impl Into<PathBuf>) -> RepoExclusiveGuard {
84-
let mut map = WORKTREE_LOCKS.lock();
85-
let git_dir = git_dir.into();
84+
let lock = {
85+
let mut map = WORKTREE_LOCKS.lock();
86+
let git_dir = git_dir.into();
87+
Arc::clone(map.entry(git_dir).or_default())
88+
};
89+
// The global `WORKTREE_LOCKS` mutex is released before blocking on the per-repo RwLock,
90+
// so contention on one repo cannot block access to unrelated repos.
8691
RepoExclusiveGuard {
87-
inner: map.entry(git_dir).or_default().write_arc().into(),
92+
inner: lock.write_arc().into(),
8893
perm: RepoExclusive(()),
8994
}
9095
}
@@ -98,9 +103,14 @@ pub fn exclusive_repo_access(git_dir: impl Into<PathBuf>) -> RepoExclusiveGuard
98103
/// alive in the caller that owns the lock, and pass [`RepoShared`] further down instead via
99104
/// [`RepoSharedGuard::read_permission()`].
100105
pub fn shared_repo_access(git_dir: impl Into<PathBuf>) -> RepoSharedGuard {
101-
let mut map = WORKTREE_LOCKS.lock();
102-
let git_dir = git_dir.into();
103-
RepoSharedGuard(Some(map.entry(git_dir).or_default().read_arc()))
106+
let lock = {
107+
let mut map = WORKTREE_LOCKS.lock();
108+
let git_dir = git_dir.into();
109+
Arc::clone(map.entry(git_dir).or_default())
110+
};
111+
// The global `WORKTREE_LOCKS` mutex is released before blocking on the per-repo RwLock,
112+
// so contention on one repo cannot block access to unrelated repos.
113+
RepoSharedGuard(Some(lock.read_arc()))
104114
}
105115

106116
/// Owns an *exclusive* in-process repository lock and releases it on drop.

crates/but-core/tests/core/sync.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use but_core::sync::{LockScope, try_exclusive_inter_process_access};
22
use but_testsupport::gix_testtools;
3+
use std::time::Duration;
34

45
#[test]
56
fn exclusive_lock_prevents_second_acquisition() -> anyhow::Result<()> {
@@ -103,3 +104,54 @@ fn default_lock_scope_is_all_operations() {
103104
let default_scope = LockScope::default();
104105
assert!(matches!(default_scope, LockScope::AllOperations));
105106
}
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

Comments
 (0)