1use super::index_lock_wait::{BackoffState, INITIAL_BACKOFF, jittered_wait, next_backoff};
14use super::json_record::quote;
15use super::live_lock::{LIVE_WAIT_VARIABLE, live_wait_limit};
16use super::owner_lock_record::{OwnerLockRecord, owner_lock_holder_is_alive};
17use super::pid_namespace::current_pid_namespace;
18use super::process_identity::current_birth_identity;
19use super::random_id::random_uuid;
20use super::worktree_copy_error::{WorktreeCopyError, failure_with_cause, raw_failure};
21use crate::worktree_copy_journal::ensure_journal_root;
22use std::io::Write;
23use std::path::{Path, PathBuf};
24use std::time::{Duration, Instant};
25
26pub const SETTLEMENT_LOCK_NAME: &str = "settlement.lock";
36
37pub const OWNER_RECORD_NAME: &str = "owner.json";
47
48pub const UNPROVEN_OWNER_TIMEOUT_MS: u64 = 1_000;
58
59const LIVE_OWNER_POLL: Duration = Duration::from_millis(20);
69
70pub const WORKTREE_COPY_LEASE_ENV: &str = "CLI_GIT_WORKTREE_COPY_LEASE";
80
81#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct LockOwner {
92 pub lease_token: String,
94 pub owner_pid: i64,
96 pub owner_birth_identity: String,
98 pub owner_pid_namespace: Option<String>,
100}
101
102#[derive(Clone, Debug, Eq, PartialEq)]
112enum Published {
113 Owner(LockOwner),
115 Retry,
117 Unproven(String),
119}
120
121pub fn encode_owner(owner: &LockOwner) -> String {
131 let namespace: String = owner
133 .owner_pid_namespace
134 .as_deref()
135 .map_or_else(String::new, |name| { return format!("\"ownerPidNamespace\":{},", quote(name)); });
136 return format!(
137 "{{\"leaseToken\":{},\"ownerBirthIdentity\":{},\"ownerPid\":{},{namespace}\"schemaVersion\":1}}\n",
138 quote(owner.lease_token.as_str()),
139 quote(owner.owner_birth_identity.as_str()),
140 owner.owner_pid
141 );
142}
143
144pub fn parse_owner(text: &str) -> Result<LockOwner, String> {
154 let value: serde_json::Value =
155 serde_json::from_str(text).map_err(|error| format!("not JSON ({error})"))?;
156 let missing: &str = "missing or mistyped owner fields";
157 let object = value
158 .as_object()
159 .ok_or_else(|| return String::from(missing))?;
160 let lease_token: &str = object
161 .get("leaseToken")
162 .and_then(serde_json::Value::as_str)
163 .filter(|token| return !token.is_empty())
164 .ok_or_else(|| return String::from(missing))?;
165 let owner_pid: i64 = object
166 .get("ownerPid")
167 .and_then(serde_json::Value::as_i64)
168 .filter(|pid| return *pid >= 1)
169 .ok_or_else(|| return String::from(missing))?;
170 let birth: &str = object
171 .get("ownerBirthIdentity")
172 .and_then(serde_json::Value::as_str)
173 .filter(|identity| return !identity.is_empty())
174 .ok_or_else(|| return String::from(missing))?;
175 if object
176 .get("schemaVersion")
177 .and_then(serde_json::Value::as_f64)
178 != Some(1.0)
179 {
180 return Err(String::from(missing));
181 }
182 return Ok(LockOwner {
183 lease_token: lease_token.to_string(),
184 owner_pid,
185 owner_birth_identity: birth.to_string(),
186 owner_pid_namespace: object
188 .get("ownerPidNamespace")
189 .and_then(serde_json::Value::as_str)
190 .filter(|namespace| { return !namespace.is_empty(); })
191 .map(String::from),
192 });
193}
194
195fn read_published(lock: &Path) -> Result<Published, WorktreeCopyError> {
205 let owner_path: PathBuf = lock.join(OWNER_RECORD_NAME);
206 let text: String = match std::fs::read_to_string(owner_path.as_path()) {
207 Ok(text) => text,
208 Err(error) => {
209 return match error.raw_os_error() {
211 Some(code) if code == libc::ENOENT || code == libc::ENOTDIR => Ok(Published::Retry),
212 Some(code) if code == libc::EACCES || code == libc::EISDIR => {
213 let name: &str = if code == libc::EACCES {
214 "EACCES"
215 } else {
216 "EISDIR"
217 };
218 Ok(Published::Unproven(format!(
219 "{} is unreadable ({name})",
220 quote(owner_path.to_string_lossy().as_ref())
221 )))
222 }
223 _ => Err(raw_failure(&error)),
224 };
225 }
226 };
227 return Ok(match parse_owner(text.as_str()) {
228 Ok(owner) => Published::Owner(owner),
229 Err(reason) => Published::Unproven(format!(
230 "{} holds an invalid owner record: {reason}",
231 quote(owner_path.to_string_lossy().as_ref())
232 )),
233 });
234}
235
236fn owner_alive(owner: &LockOwner, lock: &Path) -> Result<bool, WorktreeCopyError> {
246 let record: OwnerLockRecord = OwnerLockRecord {
247 token: owner.lease_token.clone(),
248 owner_pid: owner.owner_pid,
249 owner_birth_identity: owner.owner_birth_identity.clone(),
250 transaction_id: None,
251 owner_pid_namespace: owner.owner_pid_namespace.clone(),
252 };
253 return owner_lock_holder_is_alive(&record, lock).map_err(|error| return raw_failure(&error));
254}
255
256#[derive(Debug)]
266pub struct WorktreeCopyLease {
267 lock_directory: PathBuf,
269 owner: LockOwner,
271 released: bool,
273}
274
275impl WorktreeCopyLease {
276 pub fn lease_token(&self) -> &str {
278 return self.owner.lease_token.as_str();
279 }
280
281 pub fn release(mut self) -> Result<(), WorktreeCopyError> {
283 self.released = true;
284 if !remove_lock_of(
285 self.lock_directory.as_path(),
286 self.owner.lease_token.as_str(),
287 )? {
288 return Err(failure_with_cause(
289 format!(
290 "cli-git: worktree-copy lock ownership changed: {}.",
291 quote(self.lock_directory.to_string_lossy().as_ref())
292 )
293 .as_str(),
294 None,
295 ));
296 }
297 return Ok(());
298 }
299}
300
301impl Drop for WorktreeCopyLease {
302 fn drop(&mut self) {
304 if !self.released {
305 let _ignored: Result<bool, WorktreeCopyError> = remove_lock_of(
306 self.lock_directory.as_path(),
307 self.owner.lease_token.as_str(),
308 );
309 }
310 }
311}
312
313fn remove_lock_of(lock: &Path, lease_token: &str) -> Result<bool, WorktreeCopyError> {
324 return remove_lock_of_with(lock, lease_token, rename_lock);
325}
326
327fn rename_lock(from: &Path, to: &Path) -> std::io::Result<()> {
335 return std::fs::rename(from, to);
336}
337
338fn remove_lock_of_with(
349 lock: &Path,
350 lease_token: &str,
351 rename: fn(&Path, &Path) -> std::io::Result<()>,
352) -> Result<bool, WorktreeCopyError> {
353 match read_published(lock)? {
354 Published::Owner(owner) if owner.lease_token == lease_token => {}
355 _ => return Ok(false),
356 }
357 let retired: PathBuf = PathBuf::from(format!(
358 "{}.{}.stale",
359 lock.to_string_lossy(),
360 random_uuid().map_err(|error| return raw_failure(&error.0))?
361 ));
362 match rename(lock, retired.as_path()) {
364 Ok(()) => {}
365 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
366 Err(error) => return Err(raw_failure(&error)),
367 }
368 let moved: Published = read_published(retired.as_path())?;
369 if !matches!(&moved, Published::Owner(owner) if owner.lease_token == lease_token) {
370 let _ignored: std::io::Result<()> = std::fs::rename(retired.as_path(), lock);
372 return Ok(false);
373 }
374 std::fs::remove_dir_all(retired.as_path()).map_err(|error| return raw_failure(&error))?;
375 return Ok(true);
376}
377
378fn publish_candidate(
388 lock: &Path,
389 owner: &LockOwner,
390) -> Result<Option<WorktreeCopyLease>, WorktreeCopyError> {
391 let candidate: PathBuf = PathBuf::from(format!(
392 "{}.{}.pending",
393 lock.to_string_lossy(),
394 random_uuid().map_err(|error| return raw_failure(&error.0))?
395 ));
396 write_candidate(candidate.as_path(), owner)?;
397 match std::fs::rename(candidate.as_path(), lock) {
398 Ok(()) => {
399 return Ok(Some(WorktreeCopyLease {
400 lock_directory: lock.to_path_buf(),
401 owner: owner.clone(),
402 released: false,
403 }));
404 }
405 Err(error) => {
406 let _ignored: std::io::Result<()> = std::fs::remove_dir_all(candidate.as_path());
407 if matches!(error.kind(), std::io::ErrorKind::AlreadyExists)
409 || error.raw_os_error() == Some(libc::ENOTEMPTY)
410 {
411 return Ok(None);
412 }
413 return Err(raw_failure(&error));
414 }
415 }
416}
417
418fn write_candidate(candidate: &Path, owner: &LockOwner) -> Result<(), WorktreeCopyError> {
428 let written: std::io::Result<()> = (|| {
429 use std::os::unix::fs::DirBuilderExt;
430 std::fs::DirBuilder::new().mode(0o700).create(candidate)?;
431 crate::private_storage::write_exclusive_synced(
432 candidate.join(OWNER_RECORD_NAME).as_path(),
433 encode_owner(owner).as_bytes(),
434 )?;
435 return Ok(());
436 })();
437 if let Err(error) = written {
438 let _ignored: std::io::Result<()> = std::fs::remove_dir_all(candidate);
439 return Err(raw_failure(&error));
440 }
441 return Ok(());
442}
443
444fn current_owner() -> Result<LockOwner, WorktreeCopyError> {
454 let birth: String = current_birth_identity().map_err(|error| {
455 return failure_with_cause(
456 "cli-git: current worktree-copy lock owner identity is unavailable.",
457 Some(&error),
458 );
459 })?;
460 let token: String = random_uuid().map_err(|error| return raw_failure(&error.0))?;
461 return Ok(LockOwner {
462 lease_token: token,
463 owner_pid: i64::from(std::process::id()),
464 owner_birth_identity: birth,
465 owner_pid_namespace: current_pid_namespace(),
466 });
467}
468
469fn unit_random() -> Result<f64, WorktreeCopyError> {
479 let mut bytes: [u8; 8] = [0; 8];
480 getrandom::fill(&mut bytes).map_err(|error| return raw_failure(&error))?;
481 return Ok((u64::from_le_bytes(bytes) >> 11) as f64 / (1u64 << 53) as f64);
482}
483
484enum Attempt {
494 Acquired(WorktreeCopyLease),
496 Live(LockOwner),
498 Retry,
500 Unproven(String),
502}
503
504fn attempt(lock: &Path, owner: &LockOwner) -> Result<Attempt, WorktreeCopyError> {
514 if let Some(lease) = publish_candidate(lock, owner)? {
515 return Ok(Attempt::Acquired(lease));
516 }
517 return match read_published(lock)? {
518 Published::Retry => Ok(Attempt::Retry),
519 Published::Unproven(evidence) => Ok(Attempt::Unproven(evidence)),
520 Published::Owner(holder) => {
521 if owner_alive(&holder, lock)? {
522 return Ok(Attempt::Live(holder));
523 }
524 let _retired: bool = remove_lock_of(lock, holder.lease_token.as_str())?;
525 Ok(Attempt::Retry)
526 }
527 };
528}
529
530fn wait_while_owner_lives(lock: &Path, owner: &LockOwner, limit: Duration) -> Result<(), WorktreeCopyError> {
540 let started: Instant = Instant::now();
541 loop {
542 std::thread::sleep(LIVE_OWNER_POLL);
543 if started.elapsed() >= limit {
544 let message: String = format!(
545 "cli-git: gave up after waiting {} seconds for PID {}, which still holds worktree-copy settlement {}; {} sets how long cli-git waits. cli-git left the lock in place.",
546 started.elapsed().as_secs(),
547 owner.owner_pid,
548 quote(lock.to_string_lossy().as_ref()),
549 LIVE_WAIT_VARIABLE
550 );
551 return Err(failure_with_cause(message.as_str(), None));
552 }
553 match read_published(lock)? {
554 Published::Owner(current) if current.lease_token == owner.lease_token => {
555 if !owner_alive(¤t, lock)? {
556 return Ok(());
557 }
558 }
559 _ => return Ok(()),
560 }
561 }
562}
563
564fn live_holder_line(lock: &Path, owner: &LockOwner) -> String {
574 return format!(
575 "cli-git: waiting for PID {}, which holds worktree-copy settlement {} (proven by its live process identity); cli-git waits until it releases the lock.\n",
576 owner.owner_pid,
577 quote(lock.to_string_lossy().as_ref())
578 );
579}
580
581pub fn acquire_worktree_copy_lock(
592 common_dir: &Path,
593 stderr: &mut dyn Write,
594) -> Result<WorktreeCopyLease, WorktreeCopyError> {
595 return acquire_worktree_copy_lock_for(common_dir, stderr, live_wait_limit());
596}
597
598pub fn acquire_worktree_copy_lock_for(
609 common_dir: &Path,
610 stderr: &mut dyn Write,
611 live_limit: Duration,
612) -> Result<WorktreeCopyLease, WorktreeCopyError> {
613 let lock: PathBuf = ensure_journal_root(common_dir)?.join(SETTLEMENT_LOCK_NAME);
614 let owner: LockOwner = current_owner()?;
615 let mut named: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
616 let mut unproven_since: Option<Instant> = None;
617 let mut backoff: BackoffState = INITIAL_BACKOFF;
618 loop {
619 let unproven: Option<String> = match attempt(lock.as_path(), &owner)? {
621 Attempt::Acquired(lease) => return Ok(lease),
622 Attempt::Live(holder) => {
623 unproven_since = None;
624 if named.insert(holder.lease_token.clone()) {
625 let line: String = live_holder_line(lock.as_path(), &holder);
626 stderr
627 .write_all(line.as_bytes())
628 .map_err(|error| return raw_failure(&error))?;
629 }
630 wait_while_owner_lives(lock.as_path(), &holder, live_limit)?;
631 continue;
632 }
633 Attempt::Retry => None,
634 Attempt::Unproven(evidence) => Some(evidence),
635 };
636 let started: Instant = *unproven_since.get_or_insert_with(Instant::now);
637 if let Some(evidence) = unproven.filter(|_| {
638 return started.elapsed() >= Duration::from_millis(UNPROVEN_OWNER_TIMEOUT_MS);
639 }) {
640 {
641 let message: String = format!(
642 "cli-git: worktree-copy settlement lock {} stayed in place for {} ms and cli-git could not prove whether its owner is alive. Evidence: {}. cli-git left the lock in place; remove it only after confirming no cli-git process is using this repository.",
643 quote(lock.to_string_lossy().as_ref()),
644 UNPROVEN_OWNER_TIMEOUT_MS,
645 evidence
646 );
647 return Err(failure_with_cause(message.as_str(), None));
648 }
649 }
650 let delay_ms: u64 = jittered_wait(backoff, unit_random()?);
651 backoff = next_backoff(backoff);
652 std::thread::sleep(Duration::from_millis(delay_ms));
653 }
654}
655
656pub fn try_acquire_worktree_copy_lock(
666 common_dir: &Path,
667) -> Result<Option<WorktreeCopyLease>, WorktreeCopyError> {
668 let lock: PathBuf = ensure_journal_root(common_dir)?.join(SETTLEMENT_LOCK_NAME);
669 let owner: LockOwner = current_owner()?;
670 for _attempt in 0..10 {
672 match attempt(lock.as_path(), &owner)? {
674 Attempt::Acquired(lease) => return Ok(Some(lease)),
675 Attempt::Retry => continue,
676 Attempt::Live(_) | Attempt::Unproven(_) => return Ok(None),
677 }
678 }
679 return Err(failure_with_cause(
680 format!(
681 "cli-git: worktree-copy settlement lock under {} kept changing while it was acquired.",
682 quote(common_dir.to_string_lossy().as_ref())
683 )
684 .as_str(),
685 None,
686 ));
687}
688
689pub fn validate_inherited_lease(
699 common_dir: &Path,
700 lease_token: &str,
701) -> Result<bool, WorktreeCopyError> {
702 if lease_token.is_empty() {
703 return Ok(false);
704 }
705 let lock: PathBuf = ensure_journal_root(common_dir)?.join(SETTLEMENT_LOCK_NAME);
706 return match read_published(lock.as_path())? {
707 Published::Owner(owner) if owner.lease_token == lease_token => owner_alive(&owner, lock.as_path()),
708 _ => Ok(false),
709 };
710}
711
712#[cfg(test)]
714#[path = "worktree_copy_lock_tests.rs"]
715mod tests;