Skip to main content

socket_patch_cli/commands/
lock_cli.rs

1//! Envelope-aware wrapper around the
2//! `socket_patch_core::patch::apply_lock` advisory lock.
3//!
4//! Mutating subcommands (`apply`, `rollback`, `repair`, `remove`) all
5//! need the same shape: acquire the lock at the top of `run`, on
6//! contention emit a JSON envelope with `errorCode: "lock_held"` (or
7//! stderr in human mode) and exit 1. This module centralises that
8//! emission so the four call sites stay one line each.
9//!
10//! The lock itself is in `socket-patch-core` (cross-crate, also used
11//! by tests). This module is the CLI-side glue that knows how to
12//! render the failure through the shared [`crate::json_envelope`].
13
14use std::path::Path;
15use std::time::Duration;
16
17use socket_patch_core::patch::apply_lock::{acquire, LockError, LockGuard};
18
19use crate::json_envelope::{
20    Command, Envelope, EnvelopeError, PatchAction, PatchEvent,
21};
22
23/// Stable `errorCode` tag emitted as a `Skipped` warning event when
24/// `--break-lock` actually deletes a pre-existing lock file. Exposed
25/// for downstream consumers and integration tests that pattern-match
26/// on it.
27pub const LOCK_BROKEN_CODE: &str = "lock_broken";
28
29/// Outcome of a successful lock acquisition. Callers attach a
30/// `lock_broken` event to their own envelope when [`broke_lock`] is
31/// true, so the audit trail follows the same conventions as the
32/// rest of the command's output.
33///
34/// [`broke_lock`]: LockAcquired::broke_lock
35#[derive(Debug)]
36pub struct LockAcquired {
37    pub guard: LockGuard,
38    /// True iff `--break-lock` was set AND the helper actually
39    /// removed a pre-existing `apply.lock` file before acquiring.
40    /// False when the file didn't exist (nothing to break) — the
41    /// flag was a no-op in that case so no warning is warranted.
42    pub broke_lock: bool,
43}
44
45/// Try to acquire `<socket_dir>/apply.lock` and return the guard, or
46/// emit a failure envelope and a non-zero exit code.
47///
48/// `command` selects the envelope's `command` field so downstream
49/// consumers see `apply` / `rollback` / `repair` / `remove` rather
50/// than a generic "lock failed". `dry_run` is plumbed through to the
51/// envelope's `dry_run` field for the (rare) case where lock
52/// contention happens during a dry-run apply.
53///
54/// `timeout = Duration::ZERO` keeps the historical non-blocking
55/// try-once shape. Positive values wait with a 100 ms backoff —
56/// see `socket_patch_core::patch::apply_lock::acquire`.
57///
58/// `break_lock = true` deletes `<socket_dir>/apply.lock` before the
59/// acquire attempt. The motivating case is a crashed prior run that
60/// left the file but no OS lock. When the file exists and is
61/// successfully removed the return value's `broke_lock` is true and
62/// the caller should attach a `lock_broken` warning event to their
63/// envelope.
64pub fn acquire_or_emit(
65    socket_dir: &Path,
66    command: Command,
67    json: bool,
68    silent: bool,
69    dry_run: bool,
70    timeout: Duration,
71    break_lock: bool,
72) -> Result<LockAcquired, i32> {
73    let mut broke_lock = false;
74    if break_lock {
75        let path = socket_dir.join("apply.lock");
76        match std::fs::remove_file(&path) {
77            Ok(()) => {
78                broke_lock = true;
79                if !silent && !json {
80                    eprintln!(
81                        "Warning: --break-lock removed {} before acquisition.",
82                        path.display()
83                    );
84                }
85            }
86            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
87                // No file to break — silently proceed to the normal
88                // acquire path. Documented as a no-op so scripts can
89                // pass --break-lock unconditionally on retry.
90            }
91            Err(source) => {
92                let msg = format!(
93                    "failed to remove lock file at {}: {}",
94                    path.display(),
95                    source
96                );
97                emit(command, json, silent, dry_run, "lock_break_failed", &msg, None);
98                return Err(1);
99            }
100        }
101    }
102
103    match acquire(socket_dir, timeout) {
104        Ok(guard) => Ok(LockAcquired { guard, broke_lock }),
105        Err(LockError::Held) => {
106            let msg = if timeout > Duration::ZERO {
107                format!(
108                    "another socket-patch process is operating in this directory (waited {}s)",
109                    timeout.as_secs()
110                )
111            } else {
112                "another socket-patch process is operating in this directory".to_string()
113            };
114            emit(
115                command,
116                json,
117                silent,
118                dry_run,
119                "lock_held",
120                &msg,
121                Some(socket_dir),
122            );
123            Err(1)
124        }
125        Err(LockError::Io { path, source }) => {
126            let msg = format!("failed to open lock file at {}: {}", path.display(), source);
127            emit(command, json, silent, dry_run, "lock_io", &msg, None);
128            Err(1)
129        }
130    }
131}
132
133/// Build the warning event that callers attach to their envelope
134/// when [`LockAcquired::broke_lock`] is true. Artifact-level (no
135/// PURL) since the action targets the `.socket/` directory itself,
136/// not a specific package.
137pub fn lock_broken_event(socket_dir: &Path) -> PatchEvent {
138    PatchEvent::artifact(PatchAction::Skipped).with_reason(
139        LOCK_BROKEN_CODE,
140        format!(
141            "--break-lock removed {}/apply.lock before acquisition",
142            socket_dir.display()
143        ),
144    )
145}
146
147/// Convenience: record the `lock_broken` warning event on an
148/// envelope. Mirrors the inline pattern at each call site so we
149/// don't drift on the action / errorCode pair.
150pub fn record_lock_broken(env: &mut Envelope, socket_dir: &Path) {
151    env.record(lock_broken_event(socket_dir));
152}
153
154fn emit(
155    command: Command,
156    json: bool,
157    silent: bool,
158    dry_run: bool,
159    code: &str,
160    message: &str,
161    hint_dir: Option<&Path>,
162) {
163    if json {
164        let mut env = Envelope::new(command);
165        env.dry_run = dry_run;
166        env.mark_error(EnvelopeError::new(code, message));
167        println!("{}", env.to_pretty_json());
168    } else if !silent {
169        eprintln!("Error: {message}.");
170        if hint_dir.is_some() {
171            eprintln!(
172                "  Run `socket-patch unlock` to inspect, or rerun with --break-lock if you're sure no holder exists."
173            );
174        }
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn acquire_or_emit_succeeds_on_fresh_dir() {
184        let dir = tempfile::tempdir().unwrap();
185        let acquired = acquire_or_emit(
186            dir.path(),
187            Command::Apply,
188            false,
189            true,
190            false,
191            Duration::ZERO,
192            false,
193        )
194        .unwrap();
195        assert!(!acquired.broke_lock);
196        drop(acquired.guard);
197    }
198
199    #[test]
200    fn acquire_or_emit_returns_one_on_contention() {
201        let dir = tempfile::tempdir().unwrap();
202        let _first = acquire_or_emit(
203            dir.path(),
204            Command::Apply,
205            false,
206            true,
207            false,
208            Duration::ZERO,
209            false,
210        )
211        .unwrap();
212        let code = acquire_or_emit(
213            dir.path(),
214            Command::Apply,
215            false,
216            true,
217            false,
218            Duration::ZERO,
219            false,
220        )
221        .unwrap_err();
222        assert_eq!(code, 1);
223    }
224
225    #[test]
226    fn acquire_or_emit_returns_one_when_socket_dir_missing() {
227        let dir = tempfile::tempdir().unwrap();
228        let code = acquire_or_emit(
229            &dir.path().join("nope"),
230            Command::Apply,
231            false,
232            true,
233            false,
234            Duration::ZERO,
235            false,
236        )
237        .unwrap_err();
238        assert_eq!(code, 1);
239    }
240
241    /// Positive timeout waits then errors `lock_held` — confirms the
242    /// budget is plumbed through to `acquire`. Mirrors the
243    /// `apply_lock::tests::timeout_held` shape so a regression in
244    /// either layer surfaces here.
245    #[test]
246    fn acquire_or_emit_honors_lock_timeout() {
247        let dir = tempfile::tempdir().unwrap();
248        let _first = acquire_or_emit(
249            dir.path(),
250            Command::Apply,
251            false,
252            true,
253            false,
254            Duration::ZERO,
255            false,
256        )
257        .unwrap();
258        let start = std::time::Instant::now();
259        let code = acquire_or_emit(
260            dir.path(),
261            Command::Apply,
262            false,
263            true,
264            false,
265            Duration::from_millis(250),
266            false,
267        )
268        .unwrap_err();
269        let elapsed = start.elapsed();
270        assert_eq!(code, 1);
271        assert!(
272            elapsed >= Duration::from_millis(200),
273            "expected at least 200ms wait, got {:?}",
274            elapsed
275        );
276    }
277
278    /// `break_lock=true` against a pre-existing lock file with no
279    /// holder removes the file and acquires fresh. `broke_lock` flag
280    /// surfaces so callers can attach the warning event.
281    #[test]
282    fn acquire_or_emit_break_lock_removes_and_acquires() {
283        let dir = tempfile::tempdir().unwrap();
284        // Pre-stage a lock file with no holder — simulates the
285        // post-crash leftover scenario.
286        std::fs::write(dir.path().join("apply.lock"), b"").unwrap();
287
288        let acquired = acquire_or_emit(
289            dir.path(),
290            Command::Apply,
291            false,
292            true,
293            false,
294            Duration::ZERO,
295            true,
296        )
297        .unwrap();
298        assert!(
299            acquired.broke_lock,
300            "broke_lock should be true when a lock file existed and was removed"
301        );
302        // Lock file has been re-created by `acquire` and we hold it.
303        assert!(dir.path().join("apply.lock").is_file());
304    }
305
306    /// `break_lock=true` on a clean directory (no lock file) is a
307    /// no-op for the warning surface — `broke_lock` stays false so
308    /// callers don't emit a spurious event.
309    #[test]
310    fn acquire_or_emit_break_lock_is_noop_when_no_file() {
311        let dir = tempfile::tempdir().unwrap();
312        let acquired = acquire_or_emit(
313            dir.path(),
314            Command::Apply,
315            false,
316            true,
317            false,
318            Duration::ZERO,
319            true,
320        )
321        .unwrap();
322        assert!(
323            !acquired.broke_lock,
324            "broke_lock should be false when there was nothing to remove"
325        );
326    }
327
328    #[test]
329    fn lock_broken_event_uses_documented_code() {
330        let dir = tempfile::tempdir().unwrap();
331        let event = lock_broken_event(dir.path());
332        let v: serde_json::Value =
333            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
334        assert_eq!(v["action"], "skipped");
335        assert_eq!(v["errorCode"], LOCK_BROKEN_CODE);
336        assert!(
337            v.as_object().unwrap().get("purl").is_none(),
338            "lock_broken is an artifact-level event — no purl"
339        );
340    }
341}