Skip to main content

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, &current_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;