Skip to main content

socket_patch_cli/commands/
unlock.rs

1//! `socket-patch unlock` — inspect (and optionally release) the
2//! `<.socket>/apply.lock` advisory file lock used by mutating
3//! subcommands.
4//!
5//! Default behavior (no flags): probes the lock and prints
6//! `status: "free" | "held"`. Returns 0 when free, 1 when held —
7//! lets CI gating and monitoring tooling pattern-match the exit
8//! code without parsing JSON.
9//!
10//! With `--release`: when the lock is free, also deletes the lock
11//! file. The file is normally retained across runs (see
12//! `apply_lock` docs — the inode persists so subsequent acquires
13//! don't race on file creation), so `--release` exists for
14//! operators who want a true clean slate. Refused when the lock is
15//! held — that's the `--break-lock` flag's job on the mutating
16//! subcommands, and routing the two through different verbs makes
17//! the dangerous override explicit.
18
19use std::path::Path;
20use std::time::Duration;
21
22use clap::Args;
23use socket_patch_core::patch::apply_lock::{acquire, LockError};
24use socket_patch_core::utils::telemetry::{track_patch_unlock_failed, track_patch_unlocked};
25
26use crate::args::{apply_env_toggles, GlobalArgs};
27use crate::json_envelope::{Command, Envelope, EnvelopeError};
28
29#[derive(Args)]
30pub struct UnlockArgs {
31    #[command(flatten)]
32    pub common: GlobalArgs,
33
34    /// When the lock is free, also delete the lock file. Refused if
35    /// the lock is currently held — use `--break-lock` on the
36    /// mutating subcommand instead for that scenario.
37    #[arg(long = "release", env = "SOCKET_UNLOCK_RELEASE", default_value_t = false)]
38    pub release: bool,
39}
40
41pub async fn run(args: UnlockArgs) -> i32 {
42    apply_env_toggles(&args.common);
43
44    let socket_dir = args.common.cwd.join(".socket");
45    let lock_file = socket_dir.join("apply.lock");
46    let api_token = args.common.api_token.clone();
47    let org_slug = args.common.org.clone();
48
49    // No `.socket/` at all → treat as "free" (no one could be
50    // holding a lock that doesn't exist). Useful for fresh repos
51    // where the operator wants to confirm no stale state remains.
52    if !socket_dir.exists() {
53        // No lock to inspect → was_held=false. Nothing existed to
54        // remove, so `released` is false regardless of whether the
55        // user passed --release. Telemetry and the emitted envelope
56        // must agree on this.
57        track_patch_unlocked(false, false, api_token.as_deref(), org_slug.as_deref()).await;
58        return emit_free(args.common.json, &lock_file, false, args.release);
59    }
60
61    // Snapshot whether a lock file already exists *before* acquiring.
62    // `acquire` opens the file with `create(true)`, so after the call
63    // the file always exists — even when the operator's tree was
64    // clean. To honestly report whether `--release` removed a
65    // pre-existing leftover (vs. a file the probe itself just
66    // created), we have to capture this now.
67    let lock_existed = lock_file.exists();
68
69    match acquire(&socket_dir, Duration::ZERO) {
70        Ok(guard) => {
71            // We successfully claimed the lock — nobody else holds
72            // it. Release our handle before deleting the file so the
73            // delete races nothing.
74            drop(guard);
75
76            if args.release {
77                match std::fs::remove_file(&lock_file) {
78                    // `remove_file` here almost always returns `Ok`
79                    // (the probe's `acquire` ensured the file exists),
80                    // so we can't infer from it whether a real leftover
81                    // was present — `lock_existed` is the source of
82                    // truth for that. We still delete the file (the
83                    // operator asked for a clean slate), but only claim
84                    // we "released" something when a lock file was there
85                    // before we probed.
86                    Ok(()) => {
87                        track_patch_unlocked(
88                            false,
89                            lock_existed,
90                            api_token.as_deref(),
91                            org_slug.as_deref(),
92                        )
93                        .await;
94                        emit_free(args.common.json, &lock_file, lock_existed, true)
95                    }
96                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
97                        // The file was never created (e.g. socket
98                        // dir existed but no run has acquired the
99                        // lock yet). Treat as success.
100                        track_patch_unlocked(false, false, api_token.as_deref(), org_slug.as_deref())
101                            .await;
102                        emit_free(args.common.json, &lock_file, false, true)
103                    }
104                    Err(e) => {
105                        let msg = format!(
106                            "failed to remove lock file at {}: {}",
107                            lock_file.display(),
108                            e
109                        );
110                        track_patch_unlock_failed(&msg, api_token.as_deref(), org_slug.as_deref())
111                            .await;
112                        emit_error(args.common.json, args.common.silent, "lock_io", &msg);
113                        1
114                    }
115                }
116            } else {
117                track_patch_unlocked(false, false, api_token.as_deref(), org_slug.as_deref()).await;
118                emit_free(args.common.json, &lock_file, false, false)
119            }
120        }
121        Err(LockError::Held) => {
122            track_patch_unlock_failed(
123                "lock held by another process",
124                api_token.as_deref(),
125                org_slug.as_deref(),
126            )
127            .await;
128            if args.common.json {
129                let mut env = Envelope::new(Command::Unlock);
130                env.mark_error(EnvelopeError::new(
131                    "lock_held",
132                    format!(
133                        "another socket-patch process is operating in {}",
134                        socket_dir.display()
135                    ),
136                ));
137                println!("{}", env.to_pretty_json());
138            } else if !args.common.silent {
139                eprintln!(
140                    "Lock is held: another socket-patch process is operating in {}.",
141                    socket_dir.display()
142                );
143                if args.release {
144                    eprintln!(
145                        "  Refusing to release a held lock. Re-run the failing mutating command with --break-lock if you're sure no holder exists."
146                    );
147                } else {
148                    eprintln!(
149                        "  Re-run the failing mutating command with --break-lock if you're sure no holder exists."
150                    );
151                }
152            }
153            1
154        }
155        Err(LockError::Io { path, source }) => {
156            let msg = format!(
157                "failed to open lock file at {}: {}",
158                path.display(),
159                source
160            );
161            track_patch_unlock_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await;
162            emit_error(args.common.json, args.common.silent, "lock_io", &msg);
163            1
164        }
165    }
166}
167
168/// Print the "free" success envelope and return exit code 0.
169/// `removed` is true when `--release` actually deleted the file
170/// (vs. the no-op case where the file didn't exist).
171fn emit_free(json: bool, lock_file: &Path, removed: bool, release: bool) -> i32 {
172    if json {
173        // Build the success body by hand rather than re-using the
174        // shared `Envelope` shape — the `events`/`summary` fields
175        // don't carry useful information here, and a flat
176        // `{status, lockFile, ...}` is friendlier to jq pipelines.
177        // We still tag `command: "unlock"` so generic consumers
178        // can route on subcommand identity.
179        let body = serde_json::json!({
180            "command": "unlock",
181            "status": "free",
182            "lockFile": lock_file.display().to_string(),
183            "released": removed,
184        });
185        println!("{}", serde_json::to_string_pretty(&body).unwrap());
186    } else if release && removed {
187        println!("Lock is free. Removed {}.", lock_file.display());
188    } else if release {
189        println!("Lock is free (no lock file to remove).");
190    } else {
191        println!("Lock is free.");
192    }
193    0
194}
195
196fn emit_error(json: bool, silent: bool, code: &str, message: &str) {
197    if json {
198        let mut env = Envelope::new(Command::Unlock);
199        env.mark_error(EnvelopeError::new(code, message));
200        println!("{}", env.to_pretty_json());
201    } else if !silent {
202        eprintln!("Error: {message}.");
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use socket_patch_core::patch::apply_lock::acquire as core_acquire;
210
211    /// Build a `UnlockArgs` rooted at a tempdir for the test.
212    fn args_in(cwd: &Path, release: bool) -> UnlockArgs {
213        UnlockArgs {
214            common: GlobalArgs {
215                cwd: cwd.to_path_buf(),
216                json: true, // exercise the JSON path in unit tests
217                silent: true,
218                ..GlobalArgs::default()
219            },
220            release,
221        }
222    }
223
224    /// No `.socket/` directory at all → report `free`, exit 0.
225    /// Mirrors what a fresh `git clone` looks like.
226    #[tokio::test]
227    async fn run_reports_free_when_socket_dir_missing() {
228        let dir = tempfile::tempdir().unwrap();
229        let code = run(args_in(dir.path(), false)).await;
230        assert_eq!(code, 0);
231    }
232
233    /// `.socket/` exists but no run has taken the lock yet — still
234    /// `free`. We exercise this by creating the directory ourselves.
235    #[tokio::test]
236    async fn run_reports_free_when_socket_dir_clean() {
237        let dir = tempfile::tempdir().unwrap();
238        std::fs::create_dir_all(dir.path().join(".socket")).unwrap();
239        let code = run(args_in(dir.path(), false)).await;
240        assert_eq!(code, 0);
241    }
242
243    /// Active holder (via core `acquire`) → `unlock` reports
244    /// `held`, exits 1, and the file remains on disk.
245    #[tokio::test]
246    async fn run_reports_held_when_lock_actively_held() {
247        let dir = tempfile::tempdir().unwrap();
248        let socket_dir = dir.path().join(".socket");
249        std::fs::create_dir_all(&socket_dir).unwrap();
250
251        // Hold the lock for the duration of this test. `_guard` is
252        // bound so its drop doesn't fire until function return.
253        let _guard = core_acquire(&socket_dir, Duration::ZERO).unwrap();
254
255        let code = run(args_in(dir.path(), false)).await;
256        assert_eq!(code, 1);
257        assert!(socket_dir.join("apply.lock").is_file());
258    }
259
260    /// `--release` against a free lock with a leftover file removes
261    /// the file.
262    #[tokio::test]
263    async fn run_deletes_lock_file_when_release_and_free() {
264        let dir = tempfile::tempdir().unwrap();
265        let socket_dir = dir.path().join(".socket");
266        std::fs::create_dir_all(&socket_dir).unwrap();
267        std::fs::write(socket_dir.join("apply.lock"), b"").unwrap();
268        assert!(socket_dir.join("apply.lock").is_file());
269
270        let code = run(args_in(dir.path(), true)).await;
271        assert_eq!(code, 0);
272        assert!(
273            !socket_dir.join("apply.lock").exists(),
274            "--release should have deleted the file"
275        );
276    }
277
278    /// `--release` against a clean `.socket/` (no pre-existing lock
279    /// file) succeeds, and does not leave behind the file that the
280    /// probe's `acquire` created on demand. Guards the regression
281    /// where the probe-created file masqueraded as a released
282    /// leftover.
283    #[tokio::test]
284    async fn run_release_cleans_up_probe_created_file() {
285        let dir = tempfile::tempdir().unwrap();
286        let socket_dir = dir.path().join(".socket");
287        std::fs::create_dir_all(&socket_dir).unwrap();
288        assert!(!socket_dir.join("apply.lock").exists());
289
290        let code = run(args_in(dir.path(), true)).await;
291        assert_eq!(code, 0);
292        assert!(
293            !socket_dir.join("apply.lock").exists(),
294            "--release must not leave a probe-created lock file behind"
295        );
296    }
297
298    /// `--release` against a HELD lock refuses (exit 1), file stays.
299    #[tokio::test]
300    async fn run_refuses_release_when_held() {
301        let dir = tempfile::tempdir().unwrap();
302        let socket_dir = dir.path().join(".socket");
303        std::fs::create_dir_all(&socket_dir).unwrap();
304        let _guard = core_acquire(&socket_dir, Duration::ZERO).unwrap();
305
306        let code = run(args_in(dir.path(), true)).await;
307        assert_eq!(code, 1);
308        assert!(
309            socket_dir.join("apply.lock").is_file(),
310            "lock file should still exist — --release must refuse when held"
311        );
312    }
313}