socket_patch_cli/commands/
lock_cli.rs1use 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
23pub const LOCK_BROKEN_CODE: &str = "lock_broken";
28
29#[derive(Debug)]
36pub struct LockAcquired {
37 pub guard: LockGuard,
38 pub broke_lock: bool,
43}
44
45pub 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 }
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
133pub 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
147pub 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 #[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 #[test]
282 fn acquire_or_emit_break_lock_removes_and_acquires() {
283 let dir = tempfile::tempdir().unwrap();
284 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 assert!(dir.path().join("apply.lock").is_file());
304 }
305
306 #[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}