socket_patch_cli/commands/
unlock.rs1use 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 #[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 if !socket_dir.exists() {
53 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 let lock_existed = lock_file.exists();
68
69 match acquire(&socket_dir, Duration::ZERO) {
70 Ok(guard) => {
71 drop(guard);
75
76 if args.release {
77 match std::fs::remove_file(&lock_file) {
78 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 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
168fn emit_free(json: bool, lock_file: &Path, removed: bool, release: bool) -> i32 {
172 if json {
173 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 fn args_in(cwd: &Path, release: bool) -> UnlockArgs {
213 UnlockArgs {
214 common: GlobalArgs {
215 cwd: cwd.to_path_buf(),
216 json: true, silent: true,
218 ..GlobalArgs::default()
219 },
220 release,
221 }
222 }
223
224 #[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 #[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 #[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 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 #[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 #[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 #[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}