Skip to main content

MutationLock

Struct MutationLock 

Source
pub struct MutationLock { /* private fields */ }
Expand description

An exclusive ownership record for one installation domain.

The default acquisition path is fail-closed: ambiguous, malformed, oversized, or otherwise unreadable records never trigger automatic removal, and acquire never recovers a stale record at all. PID liveness alone cannot prove process identity across this public contract, so Core never treats it as proof.

Recovery is available, but only when a caller explicitly supplies StaleLockVerifier evidence through acquire_with_recovery, and only for the exact record that evidence classified.

Implementations§

Source§

impl MutationLock

Source

pub fn acquire( root: &Path, product: &ProductId, release: &ReleaseId, ) -> Result<Self>

Acquires the installation-root lock using create-new filesystem semantics.

This is the default and never recovers: an existing record always returns Error::UpdateInProgress and is left untouched.

Examples found in repository?
examples/stale_lock_recovery.rs (line 106)
89fn main() -> Result<(), Box<dyn std::error::Error>> {
90    let root =
91        std::env::temp_dir().join(format!("eggup-stale-lock-example-{}", std::process::id()));
92    let _ = std::fs::remove_dir_all(&root);
93    std::fs::create_dir_all(&root)?;
94
95    let product = ProductId::new("example-daemon")?;
96    let release = ReleaseId::new("2026.10.05")?;
97    let lock_path = root.join(".eggup-mutation.lock");
98
99    // A crashed updater left this record behind.
100    std::fs::write(
101        &lock_path,
102        "pid=4242 nonce=9 product=example-daemon release=2026.10.05\n",
103    )?;
104
105    println!("default acquisition never recovers:");
106    let error = MutationLock::acquire(&root, &product, &release).expect_err("record exists");
107    println!("  {error}\n");
108
109    // The consumer supervises pid 4242, can no longer see it, and the deployment
110    // is declared idle. That combination is its proof.
111    let conclusive = DeploymentVerifier {
112        evidence: DeploymentEvidence {
113            product: product.clone(),
114            supervised: vec![4242],
115            alive: Vec::new(),
116            declared_idle: true,
117        },
118    };
119    match MutationLock::acquire_with_recovery(&root, &product, &release, &conclusive) {
120        Ok(lock) => {
121            println!("recovered after caller proof:");
122            println!("  lock record: {}", lock.path().display());
123            println!("  our own record is removed when it is dropped");
124        }
125        Err(error) => println!("retained the record: {error}"),
126    }
127
128    // Contrast: the same record without the deployment's out-of-band statement is
129    // ambiguous, so it is left exactly where it is.
130    let _ = std::fs::remove_file(&lock_path);
131    std::fs::write(
132        &lock_path,
133        "pid=5150 nonce=1 product=example-daemon release=2026.10.05\n",
134    )?;
135    let cautious = DeploymentVerifier {
136        evidence: DeploymentEvidence {
137            product: product.clone(),
138            supervised: vec![5150],
139            alive: Vec::new(),
140            declared_idle: false,
141        },
142    };
143    let error = MutationLock::acquire_with_recovery(&root, &product, &release, &cautious)
144        .expect_err("no out-of-band proof");
145    println!("\nwithout out-of-band proof:");
146    println!("  {error}");
147    println!("  record still present: {}", lock_path.exists());
148
149    // And a record belonging to a different deployment is never claimed.
150    let _ = std::fs::remove_file(&lock_path);
151    std::fs::write(
152        &lock_path,
153        "pid=6060 nonce=1 product=somebody-else release=2026.10.05\n",
154    )?;
155    let foreign = DeploymentVerifier {
156        evidence: DeploymentEvidence {
157            product: ProductId::new("example-daemon")?,
158            supervised: vec![6060],
159            alive: Vec::new(),
160            declared_idle: true,
161        },
162    };
163    let error = MutationLock::acquire_with_recovery(&root, &product, &release, &foreign)
164        .expect_err("foreign record");
165    println!("\nfor another deployment's record:");
166    println!("  {error}");
167    println!("  record still present: {}", lock_path.exists());
168
169    let _ = std::fs::remove_dir_all(&root);
170    Ok(())
171}
Source

pub fn acquire_with_recovery( root: &Path, product: &ProductId, release: &ReleaseId, verifier: &dyn StaleLockVerifier, ) -> RecoveryResult<Self>

Acquires the lock, recovering a stale record the caller has proven stale.

The sequence is deliberately conservative:

  1. create-new;
  2. on an existing record, observe it within bounds;
  3. ask the caller’s verifier for a verdict on that exact observation;
  4. only on StaleLockDecision::ProvenStale, re-read the record and rename the pathname into a unique Eggup-owned claim path in the same directory;
  5. re-read the claimed object and require it to still equal the authorized observation;
  6. create-new the real lock, then delete the claimed stale record only after ownership is established.

A record that is malformed, oversized, a symlink, non-regular, or unreadable is never claimed. Active and Unknown verdicts retain the record. If the record changes before the claim, no stale deletion happens and contention is returned. If another writer creates a new lock after the claim, that writer wins and its record is never removed.

Examples found in repository?
examples/stale_lock_recovery.rs (line 119)
89fn main() -> Result<(), Box<dyn std::error::Error>> {
90    let root =
91        std::env::temp_dir().join(format!("eggup-stale-lock-example-{}", std::process::id()));
92    let _ = std::fs::remove_dir_all(&root);
93    std::fs::create_dir_all(&root)?;
94
95    let product = ProductId::new("example-daemon")?;
96    let release = ReleaseId::new("2026.10.05")?;
97    let lock_path = root.join(".eggup-mutation.lock");
98
99    // A crashed updater left this record behind.
100    std::fs::write(
101        &lock_path,
102        "pid=4242 nonce=9 product=example-daemon release=2026.10.05\n",
103    )?;
104
105    println!("default acquisition never recovers:");
106    let error = MutationLock::acquire(&root, &product, &release).expect_err("record exists");
107    println!("  {error}\n");
108
109    // The consumer supervises pid 4242, can no longer see it, and the deployment
110    // is declared idle. That combination is its proof.
111    let conclusive = DeploymentVerifier {
112        evidence: DeploymentEvidence {
113            product: product.clone(),
114            supervised: vec![4242],
115            alive: Vec::new(),
116            declared_idle: true,
117        },
118    };
119    match MutationLock::acquire_with_recovery(&root, &product, &release, &conclusive) {
120        Ok(lock) => {
121            println!("recovered after caller proof:");
122            println!("  lock record: {}", lock.path().display());
123            println!("  our own record is removed when it is dropped");
124        }
125        Err(error) => println!("retained the record: {error}"),
126    }
127
128    // Contrast: the same record without the deployment's out-of-band statement is
129    // ambiguous, so it is left exactly where it is.
130    let _ = std::fs::remove_file(&lock_path);
131    std::fs::write(
132        &lock_path,
133        "pid=5150 nonce=1 product=example-daemon release=2026.10.05\n",
134    )?;
135    let cautious = DeploymentVerifier {
136        evidence: DeploymentEvidence {
137            product: product.clone(),
138            supervised: vec![5150],
139            alive: Vec::new(),
140            declared_idle: false,
141        },
142    };
143    let error = MutationLock::acquire_with_recovery(&root, &product, &release, &cautious)
144        .expect_err("no out-of-band proof");
145    println!("\nwithout out-of-band proof:");
146    println!("  {error}");
147    println!("  record still present: {}", lock_path.exists());
148
149    // And a record belonging to a different deployment is never claimed.
150    let _ = std::fs::remove_file(&lock_path);
151    std::fs::write(
152        &lock_path,
153        "pid=6060 nonce=1 product=somebody-else release=2026.10.05\n",
154    )?;
155    let foreign = DeploymentVerifier {
156        evidence: DeploymentEvidence {
157            product: ProductId::new("example-daemon")?,
158            supervised: vec![6060],
159            alive: Vec::new(),
160            declared_idle: true,
161        },
162    };
163    let error = MutationLock::acquire_with_recovery(&root, &product, &release, &foreign)
164        .expect_err("foreign record");
165    println!("\nfor another deployment's record:");
166    println!("  {error}");
167    println!("  record still present: {}", lock_path.exists());
168
169    let _ = std::fs::remove_dir_all(&root);
170    Ok(())
171}
Source

pub fn observe(root: &Path) -> Result<Option<LockObservation>>

Observes the lock record without acquiring or removing it.

Returns Ok(None) when no record exists, and a bounded observation for a regular, non-symlink, UTF-8, in-bounds record. Records that are oversized, non-UTF-8, symlinked, non-regular, or unreadable yield Err, because Core cannot prove what they are and must therefore never displace them.

Source

pub fn inspect(root: &Path) -> Result<LockStatus>

Inspects the lock record without acquiring or removing it.

Returns LockStatus::Available when no record exists, LockStatus::Held when a bounded readable record exists, and LockStatus::Malformed when the record is missing, unreadable, oversized, or non-UTF-8. This function never deletes anything.

Source

pub fn path(&self) -> &Path

Returns the lock record path.

Examples found in repository?
examples/stale_lock_recovery.rs (line 122)
89fn main() -> Result<(), Box<dyn std::error::Error>> {
90    let root =
91        std::env::temp_dir().join(format!("eggup-stale-lock-example-{}", std::process::id()));
92    let _ = std::fs::remove_dir_all(&root);
93    std::fs::create_dir_all(&root)?;
94
95    let product = ProductId::new("example-daemon")?;
96    let release = ReleaseId::new("2026.10.05")?;
97    let lock_path = root.join(".eggup-mutation.lock");
98
99    // A crashed updater left this record behind.
100    std::fs::write(
101        &lock_path,
102        "pid=4242 nonce=9 product=example-daemon release=2026.10.05\n",
103    )?;
104
105    println!("default acquisition never recovers:");
106    let error = MutationLock::acquire(&root, &product, &release).expect_err("record exists");
107    println!("  {error}\n");
108
109    // The consumer supervises pid 4242, can no longer see it, and the deployment
110    // is declared idle. That combination is its proof.
111    let conclusive = DeploymentVerifier {
112        evidence: DeploymentEvidence {
113            product: product.clone(),
114            supervised: vec![4242],
115            alive: Vec::new(),
116            declared_idle: true,
117        },
118    };
119    match MutationLock::acquire_with_recovery(&root, &product, &release, &conclusive) {
120        Ok(lock) => {
121            println!("recovered after caller proof:");
122            println!("  lock record: {}", lock.path().display());
123            println!("  our own record is removed when it is dropped");
124        }
125        Err(error) => println!("retained the record: {error}"),
126    }
127
128    // Contrast: the same record without the deployment's out-of-band statement is
129    // ambiguous, so it is left exactly where it is.
130    let _ = std::fs::remove_file(&lock_path);
131    std::fs::write(
132        &lock_path,
133        "pid=5150 nonce=1 product=example-daemon release=2026.10.05\n",
134    )?;
135    let cautious = DeploymentVerifier {
136        evidence: DeploymentEvidence {
137            product: product.clone(),
138            supervised: vec![5150],
139            alive: Vec::new(),
140            declared_idle: false,
141        },
142    };
143    let error = MutationLock::acquire_with_recovery(&root, &product, &release, &cautious)
144        .expect_err("no out-of-band proof");
145    println!("\nwithout out-of-band proof:");
146    println!("  {error}");
147    println!("  record still present: {}", lock_path.exists());
148
149    // And a record belonging to a different deployment is never claimed.
150    let _ = std::fs::remove_file(&lock_path);
151    std::fs::write(
152        &lock_path,
153        "pid=6060 nonce=1 product=somebody-else release=2026.10.05\n",
154    )?;
155    let foreign = DeploymentVerifier {
156        evidence: DeploymentEvidence {
157            product: ProductId::new("example-daemon")?,
158            supervised: vec![6060],
159            alive: Vec::new(),
160            declared_idle: true,
161        },
162    };
163    let error = MutationLock::acquire_with_recovery(&root, &product, &release, &foreign)
164        .expect_err("foreign record");
165    println!("\nfor another deployment's record:");
166    println!("  {error}");
167    println!("  record still present: {}", lock_path.exists());
168
169    let _ = std::fs::remove_dir_all(&root);
170    Ok(())
171}

Trait Implementations§

Source§

impl Debug for MutationLock

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Drop for MutationLock

Source§

fn drop(&mut self)

Executes the destructor for this type. Read more
Source§

fn pin_drop(self: Pin<&mut Self>)

🔬This is a nightly-only experimental API. (pin_ergonomics)
Execute the destructor for this type, but different to Drop::drop, it requires self to be pinned. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = !

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, !>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.