1use super::index_lock_wait::{BackoffState, INITIAL_BACKOFF, jittered_wait, next_backoff};
14use super::json_record::quote;
15use super::wait_limit::{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::wait_progress::{Cadence, Ticker};
21use super::worktree_copy_error::{WorktreeCopyError, failure_with_cause, raw_failure};
22use crate::worktree_copy_journal::ensure_journal_root;
23use std::io::Write;
24use std::path::{Path, PathBuf};
25use std::time::{Duration, Instant};
26
27pub const SETTLEMENT_LOCK_NAME: &str = "settlement.lock";
37
38pub const OWNER_RECORD_NAME: &str = "owner.json";
48
49pub const UNPROVEN_OWNER_TIMEOUT_MS: u64 = 1_000;
59
60const LIVE_OWNER_POLL: Duration = Duration::from_millis(20);
70
71pub const WORKTREE_COPY_LEASE_ENV: &str = "CLI_GIT_WORKTREE_COPY_LEASE";
81
82#[derive(Clone, Debug, Eq, PartialEq)]
92pub struct LockOwner {
93 pub lease_token: String,
95 pub owner_pid: i64,
97 pub owner_birth_identity: String,
99 pub owner_pid_namespace: Option<String>,
101}
102
103#[derive(Clone, Debug, Eq, PartialEq)]
113enum Published {
114 Owner(LockOwner),
116 Retry,
118 Unproven(String),
120}
121
122pub fn encode_owner(owner: &LockOwner) -> String {
132 let namespace: String = owner
134 .owner_pid_namespace
135 .as_deref()
136 .map_or_else(String::new, |name| { return format!("\"ownerPidNamespace\":{},", quote(name)); });
137 return format!(
138 "{{\"leaseToken\":{},\"ownerBirthIdentity\":{},\"ownerPid\":{},{namespace}\"schemaVersion\":1}}\n",
139 quote(owner.lease_token.as_str()),
140 quote(owner.owner_birth_identity.as_str()),
141 owner.owner_pid
142 );
143}
144
145pub fn parse_owner(text: &str) -> Result<LockOwner, String> {
155 let value: serde_json::Value =
156 serde_json::from_str(text).map_err(|error| format!("not JSON ({error})"))?;
157 let missing: &str = "missing or mistyped owner fields";
158 let object = value
159 .as_object()
160 .ok_or_else(|| return String::from(missing))?;
161 let lease_token: &str = object
162 .get("leaseToken")
163 .and_then(serde_json::Value::as_str)
164 .filter(|token| return !token.is_empty())
165 .ok_or_else(|| return String::from(missing))?;
166 let owner_pid: i64 = object
167 .get("ownerPid")
168 .and_then(serde_json::Value::as_i64)
169 .filter(|pid| return *pid >= 1)
170 .ok_or_else(|| return String::from(missing))?;
171 let birth: &str = object
172 .get("ownerBirthIdentity")
173 .and_then(serde_json::Value::as_str)
174 .filter(|identity| return !identity.is_empty())
175 .ok_or_else(|| return String::from(missing))?;
176 if object
177 .get("schemaVersion")
178 .and_then(serde_json::Value::as_f64)
179 != Some(1.0)
180 {
181 return Err(String::from(missing));
182 }
183 return Ok(LockOwner {
184 lease_token: lease_token.to_string(),
185 owner_pid,
186 owner_birth_identity: birth.to_string(),
187 owner_pid_namespace: object
189 .get("ownerPidNamespace")
190 .and_then(serde_json::Value::as_str)
191 .filter(|namespace| { return !namespace.is_empty(); })
192 .map(String::from),
193 });
194}
195
196fn read_published(lock: &Path) -> Result<Published, WorktreeCopyError> {
206 let owner_path: PathBuf = lock.join(OWNER_RECORD_NAME);
207 let text: String = match std::fs::read_to_string(owner_path.as_path()) {
208 Ok(text) => text,
209 Err(error) => {
210 return match error.raw_os_error() {
212 Some(code) if code == libc::ENOENT || code == libc::ENOTDIR => Ok(Published::Retry),
213 Some(code) if code == libc::EACCES || code == libc::EISDIR => {
214 let name: &str = if code == libc::EACCES {
215 "EACCES"
216 } else {
217 "EISDIR"
218 };
219 Ok(Published::Unproven(format!(
220 "{} is unreadable ({name})",
221 quote(owner_path.to_string_lossy().as_ref())
222 )))
223 }
224 _ => Err(raw_failure(&error)),
225 };
226 }
227 };
228 return Ok(match parse_owner(text.as_str()) {
229 Ok(owner) => Published::Owner(owner),
230 Err(reason) => Published::Unproven(format!(
231 "{} holds an invalid owner record: {reason}",
232 quote(owner_path.to_string_lossy().as_ref())
233 )),
234 });
235}
236
237fn owner_alive(owner: &LockOwner, lock: &Path) -> Result<bool, WorktreeCopyError> {
247 let record: OwnerLockRecord = OwnerLockRecord {
248 token: owner.lease_token.clone(),
249 owner_pid: owner.owner_pid,
250 owner_birth_identity: owner.owner_birth_identity.clone(),
251 transaction_id: None,
252 owner_pid_namespace: owner.owner_pid_namespace.clone(),
253 };
254 return owner_lock_holder_is_alive(&record, lock).map_err(|error| return raw_failure(&error));
255}
256
257#[derive(Debug)]
267pub struct WorktreeCopyLease {
268 lock_directory: PathBuf,
270 owner: LockOwner,
272 released: bool,
274}
275
276impl WorktreeCopyLease {
277 pub fn lease_token(&self) -> &str {
279 return self.owner.lease_token.as_str();
280 }
281
282 pub fn release(mut self) -> Result<(), WorktreeCopyError> {
284 self.released = true;
285 if !remove_lock_of(
286 self.lock_directory.as_path(),
287 self.owner.lease_token.as_str(),
288 )? {
289 return Err(failure_with_cause(
290 format!(
291 "cli-git: worktree-copy lock ownership changed: {}.",
292 quote(self.lock_directory.to_string_lossy().as_ref())
293 )
294 .as_str(),
295 None,
296 ));
297 }
298 return Ok(());
299 }
300}
301
302impl Drop for WorktreeCopyLease {
303 fn drop(&mut self) {
305 if !self.released {
306 let _ignored: Result<bool, WorktreeCopyError> = remove_lock_of(
307 self.lock_directory.as_path(),
308 self.owner.lease_token.as_str(),
309 );
310 }
311 }
312}
313
314fn remove_lock_of(lock: &Path, lease_token: &str) -> Result<bool, WorktreeCopyError> {
325 return remove_lock_of_with(lock, lease_token, rename_lock);
326}
327
328fn rename_lock(from: &Path, to: &Path) -> std::io::Result<()> {
336 return std::fs::rename(from, to);
337}
338
339fn remove_lock_of_with(
350 lock: &Path,
351 lease_token: &str,
352 rename: fn(&Path, &Path) -> std::io::Result<()>,
353) -> Result<bool, WorktreeCopyError> {
354 match read_published(lock)? {
355 Published::Owner(owner) if owner.lease_token == lease_token => {}
356 _ => return Ok(false),
357 }
358 let retired: PathBuf = PathBuf::from(format!(
359 "{}.{}.stale",
360 lock.to_string_lossy(),
361 random_uuid().map_err(|error| return raw_failure(&error.0))?
362 ));
363 match rename(lock, retired.as_path()) {
365 Ok(()) => {}
366 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
367 Err(error) => return Err(raw_failure(&error)),
368 }
369 let moved: Published = read_published(retired.as_path())?;
370 if !matches!(&moved, Published::Owner(owner) if owner.lease_token == lease_token) {
371 let _ignored: std::io::Result<()> = std::fs::rename(retired.as_path(), lock);
373 return Ok(false);
374 }
375 std::fs::remove_dir_all(retired.as_path()).map_err(|error| return raw_failure(&error))?;
376 return Ok(true);
377}
378
379fn publish_candidate(
389 lock: &Path,
390 owner: &LockOwner,
391) -> Result<Option<WorktreeCopyLease>, WorktreeCopyError> {
392 let candidate: PathBuf = PathBuf::from(format!(
393 "{}.{}.pending",
394 lock.to_string_lossy(),
395 random_uuid().map_err(|error| return raw_failure(&error.0))?
396 ));
397 write_candidate(candidate.as_path(), owner)?;
398 match std::fs::rename(candidate.as_path(), lock) {
399 Ok(()) => {
400 return Ok(Some(WorktreeCopyLease {
401 lock_directory: lock.to_path_buf(),
402 owner: owner.clone(),
403 released: false,
404 }));
405 }
406 Err(error) => {
407 let _ignored: std::io::Result<()> = std::fs::remove_dir_all(candidate.as_path());
408 if matches!(error.kind(), std::io::ErrorKind::AlreadyExists)
410 || error.raw_os_error() == Some(libc::ENOTEMPTY)
411 {
412 return Ok(None);
413 }
414 return Err(raw_failure(&error));
415 }
416 }
417}
418
419fn write_candidate(candidate: &Path, owner: &LockOwner) -> Result<(), WorktreeCopyError> {
429 let written: std::io::Result<()> = (|| {
430 use std::os::unix::fs::DirBuilderExt;
431 std::fs::DirBuilder::new().mode(0o700).create(candidate)?;
432 crate::private_storage::write_exclusive_synced(
433 candidate.join(OWNER_RECORD_NAME).as_path(),
434 encode_owner(owner).as_bytes(),
435 )?;
436 return Ok(());
437 })();
438 if let Err(error) = written {
439 let _ignored: std::io::Result<()> = std::fs::remove_dir_all(candidate);
440 return Err(raw_failure(&error));
441 }
442 return Ok(());
443}
444
445fn current_owner() -> Result<LockOwner, WorktreeCopyError> {
455 let birth: String = current_birth_identity().map_err(|error| {
456 return failure_with_cause(
457 "cli-git: current worktree-copy lock owner identity is unavailable.",
458 Some(&error),
459 );
460 })?;
461 let token: String = random_uuid().map_err(|error| return raw_failure(&error.0))?;
462 return Ok(LockOwner {
463 lease_token: token,
464 owner_pid: i64::from(std::process::id()),
465 owner_birth_identity: birth,
466 owner_pid_namespace: current_pid_namespace(),
467 });
468}
469
470fn unit_random() -> Result<f64, WorktreeCopyError> {
480 let mut bytes: [u8; 8] = [0; 8];
481 getrandom::fill(&mut bytes).map_err(|error| return raw_failure(&error))?;
482 return Ok((u64::from_le_bytes(bytes) >> 11) as f64 / (1u64 << 53) as f64);
483}
484
485enum Attempt {
495 Acquired(WorktreeCopyLease),
497 Live(LockOwner),
499 Retry,
501 Unproven(String),
503}
504
505fn attempt(lock: &Path, owner: &LockOwner) -> Result<Attempt, WorktreeCopyError> {
515 if let Some(lease) = publish_candidate(lock, owner)? {
516 return Ok(Attempt::Acquired(lease));
517 }
518 return match read_published(lock)? {
519 Published::Retry => Ok(Attempt::Retry),
520 Published::Unproven(evidence) => Ok(Attempt::Unproven(evidence)),
521 Published::Owner(holder) => {
522 if owner_alive(&holder, lock)? {
523 return Ok(Attempt::Live(holder));
524 }
525 let _retired: bool = remove_lock_of(lock, holder.lease_token.as_str())?;
526 Ok(Attempt::Retry)
527 }
528 };
529}
530
531fn wait_while_owner_lives(
541 lock: &Path,
542 owner: &LockOwner,
543 limit: Duration,
544 notices: &mut dyn Write,
545 cadence: Cadence,
546) -> Result<(), WorktreeCopyError> {
547 let started: Instant = Instant::now();
548 let mut ticker: Ticker = Ticker::announced_at_start(cadence);
550 loop {
551 std::thread::sleep(LIVE_OWNER_POLL);
552 if ticker.due(started.elapsed()) {
554 let line: String = live_holder_progress_line(lock, owner, started.elapsed(), limit);
555 let _written = notices.write_all(line.as_bytes());
557 }
558 if started.elapsed() >= limit {
559 let message: String = format!(
560 "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. To go on, let that process finish and run the command again, check with `ps` whether it is stuck and stop it if so, or raise the limit.",
561 started.elapsed().as_secs(),
562 owner.owner_pid,
563 quote(lock.to_string_lossy().as_ref()),
564 LIVE_WAIT_VARIABLE
565 );
566 return Err(failure_with_cause(message.as_str(), None));
567 }
568 match read_published(lock)? {
569 Published::Owner(current) if current.lease_token == owner.lease_token => {
570 if !owner_alive(¤t, lock)? {
571 return Ok(());
572 }
573 }
574 _ => return Ok(()),
575 }
576 }
577}
578
579fn live_holder_line(lock: &Path, owner: &LockOwner, limit: Duration) -> String {
589 return format!(
590 "cli-git: waiting for PID {}, which holds worktree-copy settlement {} (proven by its live process identity); giving up after {} seconds ({} changes this), leaving the lock in place.\n",
591 owner.owner_pid,
592 quote(lock.to_string_lossy().as_ref()),
593 limit.as_secs(),
594 LIVE_WAIT_VARIABLE
595 );
596}
597
598fn live_holder_progress_line(lock: &Path, owner: &LockOwner, waited: Duration, limit: Duration) -> String {
609 return format!(
610 "cli-git: still waiting for PID {}, which holds worktree-copy settlement {} (proven by its live process identity) after {} seconds; giving up after {} seconds ({} changes this), leaving the lock in place.\n",
611 owner.owner_pid,
612 quote(lock.to_string_lossy().as_ref()),
613 waited.as_secs(),
614 limit.as_secs(),
615 LIVE_WAIT_VARIABLE
616 );
617}
618
619pub fn acquire_worktree_copy_lock(
630 common_dir: &Path,
631 stderr: &mut dyn Write,
632) -> Result<WorktreeCopyLease, WorktreeCopyError> {
633 return acquire_worktree_copy_lock_for(common_dir, stderr, live_wait_limit());
634}
635
636pub fn acquire_worktree_copy_lock_for(
647 common_dir: &Path,
648 stderr: &mut dyn Write,
649 live_limit: Duration,
650) -> Result<WorktreeCopyLease, WorktreeCopyError> {
651 return acquire_worktree_copy_lock_with(common_dir, stderr, live_limit, Cadence::from_environment());
652}
653
654pub fn acquire_worktree_copy_lock_with(
665 common_dir: &Path,
666 stderr: &mut dyn Write,
667 live_limit: Duration,
668 cadence: Cadence,
669) -> Result<WorktreeCopyLease, WorktreeCopyError> {
670 let lock: PathBuf = ensure_journal_root(common_dir)?.join(SETTLEMENT_LOCK_NAME);
671 let owner: LockOwner = current_owner()?;
672 let mut named: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
673 let mut unproven_since: Option<Instant> = None;
674 let mut backoff: BackoffState = INITIAL_BACKOFF;
675 loop {
676 let unproven: Option<String> = match attempt(lock.as_path(), &owner)? {
678 Attempt::Acquired(lease) => return Ok(lease),
679 Attempt::Live(holder) => {
680 unproven_since = None;
681 if named.insert(holder.lease_token.clone()) {
682 let line: String = live_holder_line(lock.as_path(), &holder, live_limit);
683 stderr
684 .write_all(line.as_bytes())
685 .map_err(|error| return raw_failure(&error))?;
686 }
687 wait_while_owner_lives(lock.as_path(), &holder, live_limit, stderr, cadence)?;
688 continue;
689 }
690 Attempt::Retry => None,
691 Attempt::Unproven(evidence) => Some(evidence),
692 };
693 let started: Instant = *unproven_since.get_or_insert_with(Instant::now);
694 if let Some(evidence) = unproven.filter(|_| {
695 return started.elapsed() >= Duration::from_millis(UNPROVEN_OWNER_TIMEOUT_MS);
696 }) {
697 {
698 let message: String = format!(
699 "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.",
700 quote(lock.to_string_lossy().as_ref()),
701 UNPROVEN_OWNER_TIMEOUT_MS,
702 evidence
703 );
704 return Err(failure_with_cause(message.as_str(), None));
705 }
706 }
707 let delay_ms: u64 = jittered_wait(backoff, unit_random()?);
708 backoff = next_backoff(backoff);
709 std::thread::sleep(Duration::from_millis(delay_ms));
710 }
711}
712
713pub fn try_acquire_worktree_copy_lock(
723 common_dir: &Path,
724) -> Result<Option<WorktreeCopyLease>, WorktreeCopyError> {
725 let lock: PathBuf = ensure_journal_root(common_dir)?.join(SETTLEMENT_LOCK_NAME);
726 let owner: LockOwner = current_owner()?;
727 for _attempt in 0..10 {
729 match attempt(lock.as_path(), &owner)? {
731 Attempt::Acquired(lease) => return Ok(Some(lease)),
732 Attempt::Retry => continue,
733 Attempt::Live(_) | Attempt::Unproven(_) => return Ok(None),
734 }
735 }
736 return Err(failure_with_cause(
737 format!(
738 "cli-git: worktree-copy settlement lock under {} kept changing while it was acquired.",
739 quote(common_dir.to_string_lossy().as_ref())
740 )
741 .as_str(),
742 None,
743 ));
744}
745
746pub fn validate_inherited_lease(
756 common_dir: &Path,
757 lease_token: &str,
758) -> Result<bool, WorktreeCopyError> {
759 if lease_token.is_empty() {
760 return Ok(false);
761 }
762 let lock: PathBuf = ensure_journal_root(common_dir)?.join(SETTLEMENT_LOCK_NAME);
763 return match read_published(lock.as_path())? {
764 Published::Owner(owner) if owner.lease_token == lease_token => owner_alive(&owner, lock.as_path()),
765 _ => Ok(false),
766 };
767}
768
769#[cfg(test)]
771#[path = "worktree_copy_lock_tests.rs"]
772mod tests;