git_policy_cli/owner_lock.rs
1//! What:
2//! Rename-published owner-lock directories with process-birth ownership:
3//! acquisition
4//! with dead-owner retirement,
5//! an unbounded wait while the owner lives,
6//! and release by
7//! rename before deletion.
8//! Why:
9//! The landing,
10//! reservation,
11//! hook,
12//! capture and push locks all work this way in the
13//! incumbent (`src/owner-lock/owner-lock.ts`) and in its hook dispatcher,
14//! and both
15//! wrappers contend on the same directories while both are installed.
16//! A published lock
17//! always names its owner because its candidate is complete before the rename.
18//!
19//! In TS you'd write (pseudocode):
20//! ```ts
21//! await using lock = await acquireOwnerLock({ lockDirectory });
22//! ```
23
24/// The owner record,
25/// its reading and owner liveness.
26use super::owner_lock_record::{
27 OWNER_LOCK_RECORD_FILENAME, OwnerLockRecord, OwnerRecordError, PublishedOwner,
28 encode_owner_lock_record, owner_lock_holder_is_alive, read_owner_lock_record,
29};
30/// Private creation and tree removal.
31use super::private_storage::{create_private_directory, remove_tree, write_exclusive_synced};
32/// The current process's birth identity.
33use super::process_identity::{ProcessIdentityError, current_birth_identity};
34/// Unguessable tokens and unique candidate names.
35use super::random_id::{RandomSourceError, random_uuid};
36/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
37use std::path::{Path, PathBuf};
38/// `Duration` is a span of time.
39use std::time::Duration;
40
41/// Delay between acquisition attempts while a live owner holds the lock.
42pub const DEFAULT_POLL_DELAY: Duration = Duration::from_millis(20);
43
44/// What:
45/// Why an owner-lock operation failed.
46/// Why:
47/// Each failure stops the caller with the lock named;
48/// none is a reason to delete a
49/// lock that might belong to a live owner.
50///
51/// In TS you'd write (pseudocode):
52/// ```ts
53/// class OwnerLockError extends Error {}
54/// ```
55#[derive(Debug)]
56pub enum OwnerLockError {
57 /// The blocking lock's record is malformed or unreadable.
58 Record(OwnerRecordError),
59 /// The current process has no identity,
60 /// or an owner's liveness could not be decided.
61 Identity(ProcessIdentityError),
62 /// No random token could be made.
63 Random(RandomSourceError),
64 /// A filesystem step failed.
65 Io {
66 /// What the step was doing,
67 /// in words.
68 operation: &'static str,
69 /// The path it acted on.
70 path: PathBuf,
71 /// The operating system's refusal.
72 error: std::io::Error,
73 },
74 /// The lock named another owner when this holder released it.
75 OwnershipChanged(PathBuf),
76}
77
78/// What:
79/// Human-readable text for each failure.
80/// Why:
81/// Diagnostics name the lock and the reason.
82///
83/// In TS you'd write (pseudocode):
84/// ```ts
85/// error.message
86/// ```
87impl std::fmt::Display for OwnerLockError {
88 /// Writes one sentence naming the lock and what went wrong.
89 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 match self {
91 Self::Record(OwnerRecordError::Malformed(path)) => {
92 return write!(
93 formatter,
94 "the owner lock record {} is malformed",
95 path.display()
96 );
97 }
98 Self::Record(OwnerRecordError::Unreadable(path, error)) => {
99 return write!(
100 formatter,
101 "the owner lock record {} could not be read: {error}",
102 path.display()
103 );
104 }
105 Self::Identity(error) => return write!(formatter, "{error}"),
106 Self::Random(error) => return write!(formatter, "{}", error.0),
107 Self::Io {
108 operation,
109 path,
110 error,
111 } => {
112 return write!(formatter, "{operation} {} failed: {error}", path.display());
113 }
114 Self::OwnershipChanged(path) => {
115 return write!(
116 formatter,
117 "the owner lock {} changed owner while it was held",
118 path.display()
119 );
120 }
121 }
122 }
123}
124
125/// What:
126/// Wrap a filesystem error with the step and path that failed.
127/// Why:
128/// One constructor keeps every filesystem failure named the same way.
129///
130/// In TS you'd write (pseudocode):
131/// ```ts
132/// new OwnerLockError(`${operation} ${path} failed: ${error.message}`)
133/// ```
134fn io_error(operation: &'static str, path: &Path, error: std::io::Error) -> OwnerLockError {
135 return OwnerLockError::Io {
136 operation,
137 path: path.to_path_buf(),
138 error,
139 };
140}
141
142/// What:
143/// Whether a rename failed because another directory already holds the name.
144/// Why:
145/// `rename` onto a non-empty directory fails with `EEXIST` or `ENOTEMPTY` on Unix and
146/// with an access-denied error on Windows (Node's `EPERM`);
147/// the incumbent's
148/// `isOccupiedError` accepts exactly these.
149///
150/// In TS you'd write (pseudocode):
151/// ```ts
152/// function isOccupiedError(error: unknown): boolean;
153/// ```
154pub fn is_occupied_error(error: &std::io::Error) -> bool {
155 return matches!(
156 error.kind(),
157 std::io::ErrorKind::AlreadyExists
158 | std::io::ErrorKind::DirectoryNotEmpty
159 | std::io::ErrorKind::PermissionDenied
160 );
161}
162
163/// What:
164/// A unique sibling name `<lock>.<uuid>.<suffix>` for a candidate or a retired lock.
165/// Why:
166/// Candidates (`.pending`) and retired locks (`.stale`) never collide with each other,
167/// and every reader ignores both names.
168///
169/// In TS you'd write (pseudocode):
170/// ```ts
171/// `${lockDirectory}.${randomUUID()}.${suffix}`
172/// ```
173fn sibling_name(lock_directory: &Path, suffix: &str) -> Result<PathBuf, OwnerLockError> {
174 let unique: String = random_uuid().map_err(OwnerLockError::Random)?;
175 // `as_os_str().to_owned()` copies the raw path bytes so the suffix can be appended.
176 let mut name: std::ffi::OsString = lock_directory.as_os_str().to_owned();
177 name.push(format!(".{unique}.{suffix}"));
178 return Ok(PathBuf::from(name));
179}
180
181/// What:
182/// Read a lock's owner,
183/// turning record failures into lock failures.
184/// Why:
185/// Every step of acquisition and release reads the owner the same way.
186///
187/// In TS you'd write (pseudocode):
188/// ```ts
189/// await readOwnerLockRecord(lockDirectory)
190/// ```
191fn read_owner(lock_directory: &Path) -> Result<PublishedOwner, OwnerLockError> {
192 return read_owner_lock_record(lock_directory).map_err(OwnerLockError::Record);
193}
194
195/// What:
196/// A held owner lock.
197/// Releasing it proves the lock still names this holder first.
198/// Dropping an unreleased lock releases it and ignores a failure.
199/// Why:
200/// An early return or an unwinding error must not leave a live-looking lock behind;
201/// an explicit `release` reports a changed owner.
202///
203/// In TS you'd write (pseudocode):
204/// ```ts
205/// type OwnerLock = AsyncDisposable & { lockDirectory: string; token: string };
206/// ```
207#[derive(Debug)]
208pub struct OwnerLock {
209 /// Published lock directory.
210 directory: PathBuf,
211 /// Token published in the lock.
212 token: String,
213 /// Whether the lock was already released.
214 released: bool,
215}
216
217/// Accessors and release of a held lock.
218impl OwnerLock {
219 /// What:
220 /// A view of a lock an ancestor process holds, which releases nothing.
221 /// Why:
222 /// An invocation nested under a landing-lock holder proves it with an inherited lease and proceeds under the
223 /// ancestor's lock instead of waiting for it (`inheritedLandingLock` in the incumbent).
224 ///
225 /// In TS you'd write (pseudocode):
226 /// ```ts
227 /// { lockDirectory, token: process.env[LANDING_LEASE_ENV], [Symbol.asyncDispose]: async () => {} }
228 /// ```
229 pub fn inherited(directory: &Path, token: &str) -> OwnerLock {
230 return OwnerLock {
231 directory: directory.to_path_buf(),
232 token: String::from(token),
233 released: true,
234 };
235 }
236
237 /// What:
238 /// The published lock directory.
239 /// Why:
240 /// Leases and diagnostics name the lock.
241 ///
242 /// In TS you'd write (pseudocode):
243 /// ```ts
244 /// lock.lockDirectory
245 /// ```
246 pub fn directory(&self) -> &Path {
247 return self.directory.as_path();
248 }
249
250 /// What:
251 /// The token this holder published.
252 /// Why:
253 /// A lease proves which acquisition it belongs to.
254 ///
255 /// In TS you'd write (pseudocode):
256 /// ```ts
257 /// lock.token
258 /// ```
259 pub fn token(&self) -> &str {
260 return self.token.as_str();
261 }
262
263 /// What:
264 /// Release the lock,
265 /// failing when it no longer names this holder.
266 /// `mut self` takes ownership:
267 /// a released lock cannot be used again.
268 /// Why:
269 /// The release is the incumbent's `releaseHeldLock`.
270 ///
271 /// In TS you'd write (pseudocode):
272 /// ```ts
273 /// await lock[Symbol.asyncDispose]();
274 /// ```
275 pub fn release(mut self) -> Result<(), OwnerLockError> {
276 // A view of an ancestor's lock was never taken by this holder, so it has nothing to release.
277 if self.released {
278 return Ok(());
279 }
280 self.released = true;
281 return release_held_lock(self.directory.as_path(), self.token.as_str());
282 }
283}
284
285/// Dropping an unreleased lock releases it.
286impl Drop for OwnerLock {
287 /// What:
288 /// Release the lock unless `release` already did;
289 /// a failure is ignored.
290 /// Why:
291 /// Nothing can be reported from a drop,
292 /// and a lock that changed owner is not this
293 /// holder's to remove.
294 ///
295 /// In TS you'd write (pseudocode):
296 /// ```ts
297 /// // `await using` disposal on an early exit.
298 /// ```
299 fn drop(&mut self) {
300 if !self.released {
301 self.released = true;
302 let _ = release_held_lock(self.directory.as_path(), self.token.as_str());
303 }
304 }
305}
306
307/// What:
308/// Move a renamed-away directory back to the published name when it turned out to
309/// belong to someone else.
310/// Why:
311/// A race can move a live replacement aside;
312/// putting it back is all that can be done,
313/// and an occupied name means a newer owner already published,
314/// which is left alone.
315///
316/// In TS you'd write (pseudocode):
317/// ```ts
318/// try { await rename(moved, lockDirectory); } catch (e) { if (!isOccupiedError(e)) throw e; }
319/// ```
320fn restore_moved(moved: &Path, lock_directory: &Path) -> Result<(), OwnerLockError> {
321 match std::fs::rename(moved, lock_directory) {
322 Ok(()) => return Ok(()),
323 Err(error) if is_occupied_error(&error) => return Ok(()),
324 Err(error) => return Err(io_error("restoring the owner lock", lock_directory, error)),
325 }
326}
327
328/// What:
329/// Release a held lock by renaming it away,
330/// then deleting the renamed copy.
331/// Why:
332/// Emptying the published directory in place would let a concurrent acquirer's rename
333/// replace it mid-removal and lose the next owner's record;
334/// the rename frees the name
335/// atomically.
336/// A lock that names another owner before or after the move is reported.
337///
338/// In TS you'd write (pseudocode):
339/// ```ts
340/// async function releaseHeldLock({ lockDirectory, token }): Promise<void>;
341/// ```
342fn release_held_lock(lock_directory: &Path, token: &str) -> Result<(), OwnerLockError> {
343 if !names_token(&read_owner(lock_directory)?, token) {
344 return Err(OwnerLockError::OwnershipChanged(
345 lock_directory.to_path_buf(),
346 ));
347 }
348 let retired: PathBuf = sibling_name(lock_directory, "stale")?;
349 if let Err(error) = std::fs::rename(lock_directory, retired.as_path()) {
350 return Err(io_error("releasing the owner lock", lock_directory, error));
351 }
352 return settle_moved_release(retired.as_path(), lock_directory, token);
353}
354
355/// What:
356/// Finish a release after the lock was renamed away:
357/// delete it when it is this holder's,
358/// or put it back and report the change when a race moved someone else's lock.
359/// Why:
360/// Between the owner check and the rename another process may have replaced the lock;
361/// the moved copy is checked again before anything is deleted.
362///
363/// In TS you'd write (pseudocode):
364/// ```ts
365/// const moved = await readOwnerLockRecord(retired); if (moved.token !== token) { await restore(); throw ...; } await rm(retired);
366/// ```
367fn settle_moved_release(
368 retired: &Path,
369 lock_directory: &Path,
370 token: &str,
371) -> Result<(), OwnerLockError> {
372 if !names_token(&read_owner(retired)?, token) {
373 restore_moved(retired, lock_directory)?;
374 return Err(OwnerLockError::OwnershipChanged(
375 lock_directory.to_path_buf(),
376 ));
377 }
378 if let Err(error) = remove_tree(retired) {
379 return Err(io_error("removing the released lock", retired, error));
380 }
381 return Ok(());
382}
383
384/// What:
385/// Whether a read owner is present and published `token`.
386/// Why:
387/// Release and retirement both ask whether the lock still holds a given acquisition.
388///
389/// In TS you'd write (pseudocode):
390/// ```ts
391/// (owner !== LOCK_BUSY) && (owner.token === token)
392/// ```
393fn names_token(owner: &PublishedOwner, token: &str) -> bool {
394 match owner {
395 PublishedOwner::Owner(record) => return record.token == token,
396 PublishedOwner::Busy => return false,
397 }
398}
399
400/// What:
401/// Retire a lock whose owner was found dead,
402/// unless it changed owner meanwhile.
403/// Why:
404/// The liveness check took time,
405/// during which the dead-looking owner may have released
406/// and a live acquirer published.
407/// The lock is renamed to a unique stale name,
408/// and a live
409/// replacement moved by the race is restored.
410/// The caller retries afterwards either way.
411///
412/// In TS you'd write (pseudocode):
413/// ```ts
414/// async function retireDeadLock({ lockDirectory, deadToken }): Promise<typeof LOCK_BUSY>;
415/// ```
416fn retire_dead_lock(lock_directory: &Path, dead_token: &str) -> Result<(), OwnerLockError> {
417 if !names_token(&read_owner(lock_directory)?, dead_token) {
418 return Ok(());
419 }
420 let stale: PathBuf = sibling_name(lock_directory, "stale")?;
421 match std::fs::rename(lock_directory, stale.as_path()) {
422 Ok(()) => {}
423 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
424 Err(error) => {
425 return Err(io_error(
426 "retiring the dead owner lock",
427 lock_directory,
428 error,
429 ));
430 }
431 }
432 return settle_moved_retirement(stale.as_path(), lock_directory, dead_token);
433}
434
435/// What:
436/// Finish a retirement after the lock was renamed away:
437/// delete the dead owner's lock,
438/// or put back a live replacement that a race moved instead.
439/// Why:
440/// A concurrent acquirer may have published between the owner check and the rename;
441/// a
442/// moved lock that names another token is not the dead owner's to delete.
443///
444/// In TS you'd write (pseudocode):
445/// ```ts
446/// const moved = await readOwnerLockRecord(stale); if (moved !== LOCK_BUSY && moved.token !== deadToken) { await restore(); return; } await rm(stale);
447/// ```
448fn settle_moved_retirement(
449 stale: &Path,
450 lock_directory: &Path,
451 dead_token: &str,
452) -> Result<(), OwnerLockError> {
453 if let PublishedOwner::Owner(record) = read_owner(stale)?
454 && record.token != dead_token
455 {
456 return restore_moved(stale, lock_directory);
457 }
458 if let Err(error) = remove_tree(stale) {
459 return Err(io_error("removing the retired lock", stale, error));
460 }
461 return Ok(());
462}
463
464/// What:
465/// Write a complete unpublished candidate:
466/// a private directory and its owner record.
467/// Why:
468/// Publication by rename makes a lock appear with its owner record already complete.
469///
470/// In TS you'd write (pseudocode):
471/// ```ts
472/// async function writeCandidate({ candidateDirectory, record }): Promise<void>;
473/// ```
474fn write_candidate(candidate: &Path, record: &OwnerLockRecord) -> Result<(), OwnerLockError> {
475 if let Err(error) = create_private_directory(candidate) {
476 return Err(io_error("creating the lock candidate", candidate, error));
477 }
478 let record_path: PathBuf = candidate.join(OWNER_LOCK_RECORD_FILENAME);
479 let bytes: String = encode_owner_lock_record(record);
480 if let Err(error) = write_exclusive_synced(record_path.as_path(), bytes.as_bytes()) {
481 return Err(io_error(
482 "writing the lock record",
483 record_path.as_path(),
484 error,
485 ));
486 }
487 return Ok(());
488}
489
490/// What:
491/// One publication attempt:
492/// publish a fresh candidate,
493/// or retire a dead owner's lock
494/// that blocks it.
495/// `Ok(None)` means busy:
496/// try again.
497/// Why:
498/// The incumbent's `attemptAcquire`,
499/// step for step.
500///
501/// In TS you'd write (pseudocode):
502/// ```ts
503/// async function attemptAcquire({ lockDirectory, record }): Promise<OwnerLock | typeof LOCK_BUSY>;
504/// ```
505fn attempt_acquire(
506 lock_directory: &Path,
507 record: &OwnerLockRecord,
508) -> Result<Option<OwnerLock>, OwnerLockError> {
509 let candidate: PathBuf = sibling_name(lock_directory, "pending")?;
510 write_candidate(candidate.as_path(), record)?;
511 match std::fs::rename(candidate.as_path(), lock_directory) {
512 Ok(()) => {
513 return Ok(Some(OwnerLock {
514 directory: lock_directory.to_path_buf(),
515 token: record.token.clone(),
516 released: false,
517 }));
518 }
519 Err(error) => {
520 if let Err(removal) = remove_tree(candidate.as_path()) {
521 return Err(io_error(
522 "removing the lock candidate",
523 candidate.as_path(),
524 removal,
525 ));
526 }
527 if !is_occupied_error(&error) {
528 return Err(io_error("publishing the owner lock", lock_directory, error));
529 }
530 }
531 }
532 let PublishedOwner::Owner(published) = read_owner(lock_directory)? else {
533 return Ok(None);
534 };
535 if owner_lock_holder_is_alive(&published).map_err(OwnerLockError::Identity)? {
536 return Ok(None);
537 }
538 retire_dead_lock(lock_directory, published.token.as_str())?;
539 return Ok(None);
540}
541
542/// What:
543/// A fresh owner record for the current process.
544/// Why:
545/// Every acquisition publishes a new token with this process's PID and birth identity.
546///
547/// In TS you'd write (pseudocode):
548/// ```ts
549/// async function currentOwnerRecord(transactionId?: string): Promise<OwnerLockRecord>;
550/// ```
551fn current_owner_record(transaction_id: Option<&str>) -> Result<OwnerLockRecord, OwnerLockError> {
552 return Ok(OwnerLockRecord {
553 token: random_uuid().map_err(OwnerLockError::Random)?,
554 owner_pid: i64::from(std::process::id()),
555 owner_birth_identity: current_birth_identity().map_err(OwnerLockError::Identity)?,
556 // `.map(String::from)` copies the borrowed ID into owned text when there is one.
557 transaction_id: transaction_id.map(String::from),
558 });
559}
560
561/// What:
562/// One publication attempt without waiting.
563/// `Ok(None)` means another owner holds the
564/// lock,
565/// or a dead owner's lock was just retired and the caller should try again.
566/// Why:
567/// The reservation is taken this way,
568/// by a requester that re-checks its turn between
569/// attempts.
570///
571/// In TS you'd write (pseudocode):
572/// ```ts
573/// async function tryAcquireOwnerLock({ lockDirectory, transactionId }): Promise<OwnerLock | typeof LOCK_BUSY>;
574/// ```
575pub fn try_acquire_owner_lock(
576 lock_directory: &Path,
577 transaction_id: Option<&str>,
578) -> Result<Option<OwnerLock>, OwnerLockError> {
579 return attempt_acquire(lock_directory, ¤t_owner_record(transaction_id)?);
580}
581
582/// What:
583/// Retire a lock whose recorded owner is dead;
584/// an absent lock or a live owner's lock is
585/// left alone.
586/// Why:
587/// Recovery retires a dead reservation holder's lock this way.
588///
589/// In TS you'd write (pseudocode):
590/// ```ts
591/// async function retireLockOfDeadOwner(lockDirectory: string): Promise<void>;
592/// ```
593pub fn retire_lock_of_dead_owner(lock_directory: &Path) -> Result<(), OwnerLockError> {
594 let PublishedOwner::Owner(published) = read_owner(lock_directory)? else {
595 return Ok(());
596 };
597 if owner_lock_holder_is_alive(&published).map_err(OwnerLockError::Identity)? {
598 return Ok(());
599 }
600 return retire_dead_lock(lock_directory, published.token.as_str());
601}
602
603/// What:
604/// Something told once that an acquirer has to wait,
605/// such as a stderr notice.
606/// A `trait` is an interface a type implements.
607/// Why:
608/// A waiter may tell the user whose lock it waits for;
609/// the lock module stays silent.
610///
611/// In TS you'd write (pseudocode):
612/// ```ts
613/// type OnWait = () => void;
614/// ```
615pub trait WaitNotice {
616 /// Called once,
617 /// when the first attempt finds the lock held.
618 fn waiting(&mut self);
619}
620
621/// What:
622/// The notice that says nothing.
623/// Why:
624/// Most acquirers wait silently,
625/// as the incumbent's default.
626///
627/// In TS you'd write (pseudocode):
628/// ```ts
629/// const onWait = undefined;
630/// ```
631#[derive(Clone, Copy, Debug, Default)]
632pub struct SilentWait;
633
634/// Waiting silently does nothing.
635impl WaitNotice for SilentWait {
636 /// Does nothing.
637 fn waiting(&mut self) {}
638}
639
640/// What:
641/// Acquire a lock,
642/// waiting without bound while a live owner holds it.
643/// `notice` is told once,
644/// when the first attempt finds the lock busy;
645/// `&mut dyn WaitNotice` lends any value implementing that interface.
646/// Why:
647/// The wait is unbounded by contract while the owner lives;
648/// a dead owner's lock is
649/// retired on the next attempt.
650/// One process record is used for every attempt.
651///
652/// In TS you'd write (pseudocode):
653/// ```ts
654/// async function acquireOwnerLock({ lockDirectory, pollDelayMs = 20, onWait }): Promise<OwnerLock>;
655/// ```
656pub fn acquire_owner_lock(
657 lock_directory: &Path,
658 poll_delay: Duration,
659 notice: &mut dyn WaitNotice,
660) -> Result<OwnerLock, OwnerLockError> {
661 let record: OwnerLockRecord = current_owner_record(None)?;
662 // `mut` lets the loop remember that the wait was announced.
663 let mut notified: bool = false;
664 loop {
665 if let Some(lock) = attempt_acquire(lock_directory, &record)? {
666 return Ok(lock);
667 }
668 if !notified {
669 notified = true;
670 notice.waiting();
671 }
672 std::thread::sleep(poll_delay);
673 }
674}
675
676/// Lock contention,
677/// retirement and release controls stay out of the release executable.
678#[cfg(test)]
679#[path = "owner_lock_tests.rs"]
680mod tests;