-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathunlock.rs
More file actions
460 lines (426 loc) · 18.8 KB
/
Copy pathunlock.rs
File metadata and controls
460 lines (426 loc) · 18.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
//! `socket-patch unlock` — inspect (and optionally release) the
//! `<.socket>/apply.lock` advisory file lock used by mutating
//! subcommands.
//!
//! Default behavior (no flags): probes the lock and prints
//! `status: "free" | "held"`. Returns 0 when free, 1 when held —
//! lets CI gating and monitoring tooling pattern-match the exit
//! code without parsing JSON.
//!
//! With `--release`: when the lock is free, also deletes the lock
//! file. The file is normally retained across runs (see
//! `apply_lock` docs — the inode persists so subsequent acquires
//! don't race on file creation), so `--release` exists for
//! operators who want a true clean slate. Refused when the lock is
//! held — that's the `--break-lock` flag's job on the mutating
//! subcommands, and routing the two through different verbs makes
//! the dangerous override explicit. Under the global `--dry-run`
//! flag the release is previewed, not performed: the leftover file
//! stays on disk and the JSON body carries `dryRun` + `wouldRelease`
//! instead of flipping `released`.
use std::path::Path;
use std::time::Duration;
use clap::Args;
use socket_patch_core::patch::apply_lock::{acquire, LockError};
use socket_patch_core::utils::telemetry::{track_patch_unlock_failed, track_patch_unlocked};
use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs};
use crate::commands::lock_cli::error_envelope;
use crate::json_envelope::Command;
#[derive(Args)]
pub struct UnlockArgs {
#[command(flatten)]
pub common: GlobalArgs,
/// When the lock is free, also delete the lock file. Refused if
/// the lock is currently held — use `--break-lock` on the
/// mutating subcommand instead for that scenario.
///
/// `value_parser = parse_bool_flag` matches the `GlobalArgs` bool
/// flags: clap's default bool parser accepts only the literal
/// strings `true`/`false` from the env binding, so
/// `SOCKET_UNLOCK_RELEASE=1` (or an exported-but-empty
/// `SOCKET_UNLOCK_RELEASE=`) aborted every `unlock` invocation.
/// (`main`'s empty-var scrub also removes a blank
/// `SOCKET_UNLOCK_RELEASE` via `LOCAL_ARG_ENV_VARS`, but the
/// parser itself must not depend on it — library callers of
/// `Cli::parse` never run the scrub.)
#[arg(
long = "release",
env = "SOCKET_UNLOCK_RELEASE",
default_value_t = false,
value_parser = parse_bool_flag,
)]
pub release: bool,
}
pub async fn run(args: UnlockArgs) -> i32 {
apply_env_toggles(&args.common);
// Derive the lock directory exactly like the mutating subcommands
// do (`manifest_path.parent()`) — they're the processes whose lock
// this command exists to observe. Hardcoding `<cwd>/.socket` here
// would probe a directory nobody locks whenever `--manifest-path`
// points elsewhere.
let manifest_path = args.common.resolved_manifest_path();
let socket_dir = manifest_path.parent().unwrap_or(Path::new("."));
let lock_file = socket_dir.join("apply.lock");
let api_token = args.common.api_token.as_deref();
let org_slug = args.common.org.as_deref();
// No `.socket/` at all → treat as "free" (no one could be
// holding a lock that doesn't exist). Useful for fresh repos
// where the operator wants to confirm no stale state remains.
if !socket_dir.exists() {
// No lock to inspect → was_held=false. Nothing existed to
// remove, so `released` is false regardless of whether the
// user passed --release. Telemetry and the emitted envelope
// must agree on this.
track_patch_unlocked(false, false, api_token, org_slug).await;
return emit_free(
args.common.json,
args.common.silent,
&lock_file,
false,
false,
args.release,
args.common.dry_run,
);
}
// Snapshot whether a lock file already exists *before* acquiring.
// `acquire` opens the file with `create(true)`, so after the call
// the file always exists — even when the operator's tree was
// clean. To honestly report whether `--release` removed a
// pre-existing leftover (vs. a file the probe itself just
// created), we have to capture this now.
let lock_existed = lock_file.exists();
match acquire(socket_dir, Duration::ZERO) {
Ok(guard) => {
// We successfully claimed the lock — nobody else holds
// it. Release our handle before deleting the file so the
// delete races nothing.
drop(guard);
let removed = if args.release && !args.common.dry_run {
match std::fs::remove_file(&lock_file) {
// `remove_file` here almost always returns `Ok`
// (the probe's `acquire` ensured the file exists),
// so we can't infer from it whether a real leftover
// was present — `lock_existed` is the source of
// truth for that. We still delete the file (the
// operator asked for a clean slate), but only claim
// we "released" something when a lock file was there
// before we probed.
Ok(()) => lock_existed,
// NotFound: the file was never created (e.g. socket
// dir existed but no run has acquired the lock yet).
// Treat as success.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => {
let msg = format!(
"failed to remove lock file at {}: {}",
lock_file.display(),
e
);
track_patch_unlock_failed(&msg, api_token, org_slug).await;
emit_error(
args.common.json,
args.common.silent,
args.common.dry_run,
"lock_io",
&msg,
);
return 1;
}
}
} else {
// `--dry-run` previews the release without performing it —
// the pre-existing leftover (the thing a real `--release`
// would delete) stays on disk. Deleting a file the probe
// itself just created is NOT the previewed mutation though:
// leaving it would make the dry run the only mode that
// *adds* state, so restore the tree to what we found.
if args.common.dry_run && !lock_existed {
let _ = std::fs::remove_file(&lock_file);
}
false
};
let would_remove = args.release && args.common.dry_run && lock_existed;
track_patch_unlocked(false, removed, api_token, org_slug).await;
emit_free(
args.common.json,
args.common.silent,
&lock_file,
removed,
would_remove,
args.release,
args.common.dry_run,
)
}
Err(LockError::Held) => {
track_patch_unlock_failed("lock held by another process", api_token, org_slug).await;
let msg = format!(
"another socket-patch process is operating in {}",
socket_dir.display()
);
if args.common.json {
let env = error_envelope(Command::Unlock, args.common.dry_run, "lock_held", &msg);
println!("{}", env.to_pretty_json());
} else if !args.common.silent {
eprintln!("Lock is held: {msg}.");
if args.release {
eprintln!(
" Refusing to release a held lock. Re-run the failing mutating command with --break-lock if you're sure no holder exists."
);
} else {
eprintln!(
" Re-run the failing mutating command with --break-lock if you're sure no holder exists."
);
}
}
1
}
Err(LockError::Io { path, source }) => {
let msg = format!("failed to open lock file at {}: {}", path.display(), source);
track_patch_unlock_failed(&msg, api_token, org_slug).await;
emit_error(
args.common.json,
args.common.silent,
args.common.dry_run,
"lock_io",
&msg,
);
1
}
}
}
/// Print the "free" success envelope and return exit code 0.
/// `removed` is true when `--release` actually deleted the file
/// (vs. the no-op case where the file didn't exist). `would_remove`
/// is the `--dry-run` preview of `removed`: a pre-existing leftover
/// that a real `--release` would have deleted.
/// `silent` suppresses the human-readable lines (the JSON envelope is
/// machine output and always prints) — same `--silent` contract as the
/// sibling subcommands.
fn emit_free(
json: bool,
silent: bool,
lock_file: &Path,
removed: bool,
would_remove: bool,
release: bool,
dry_run: bool,
) -> i32 {
if json {
// Build the success body by hand rather than re-using the
// shared `Envelope` shape — the `events`/`summary` fields
// don't carry useful information here, and a flat
// `{status, lockFile, ...}` is friendlier to jq pipelines.
// We still tag `command: "unlock"` so generic consumers
// can route on subcommand identity.
let mut body = serde_json::json!({
"command": "unlock",
"status": "free",
"lockFile": lock_file.display().to_string(),
"released": removed,
});
// `released` stays truthful on a dry run (nothing was deleted),
// so the preview rides separate additive fields — same shape as
// `setup`'s dry-run-only `dryRun`/`wouldUpdate` pair.
if dry_run {
body["dryRun"] = serde_json::json!(true);
if release {
body["wouldRelease"] = serde_json::json!(would_remove);
}
}
println!("{}", serde_json::to_string_pretty(&body).unwrap());
} else if silent {
// Suppress the informational lines; the exit code carries the verdict.
} else if release && removed {
println!("Lock is free. Removed {}.", lock_file.display());
} else if would_remove {
println!(
"Lock is free. Would remove {} (dry run).",
lock_file.display()
);
} else if release {
println!("Lock is free (no lock file to remove).");
} else {
println!("Lock is free.");
}
0
}
fn emit_error(json: bool, silent: bool, dry_run: bool, code: &str, message: &str) {
if json {
let env = error_envelope(Command::Unlock, dry_run, code, message);
println!("{}", env.to_pretty_json());
} else if !silent {
eprintln!("Error: {message}.");
}
}
#[cfg(test)]
mod tests {
use super::*;
use socket_patch_core::patch::apply_lock::acquire as core_acquire;
/// Build a `UnlockArgs` rooted at a tempdir for the test.
fn args_in(cwd: &Path, release: bool) -> UnlockArgs {
UnlockArgs {
common: GlobalArgs {
cwd: cwd.to_path_buf(),
json: true, // exercise the JSON path in unit tests
silent: true,
..GlobalArgs::default()
},
release,
}
}
/// No `.socket/` directory at all → report `free`, exit 0.
/// Mirrors what a fresh `git clone` looks like.
#[tokio::test]
async fn run_reports_free_when_socket_dir_missing() {
let dir = tempfile::tempdir().unwrap();
let code = run(args_in(dir.path(), false)).await;
assert_eq!(code, 0);
}
/// `.socket/` exists but no run has taken the lock yet — still
/// `free`. We exercise this by creating the directory ourselves.
#[tokio::test]
async fn run_reports_free_when_socket_dir_clean() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".socket")).unwrap();
let code = run(args_in(dir.path(), false)).await;
assert_eq!(code, 0);
}
/// A stale lock *file* left on disk by a crashed run — with **no**
/// live OS holder — must read as `free` (exit 0), and a plain probe
/// must leave that file in place. Guards against a regression where
/// the verdict keys off `apply.lock` merely *existing* rather than
/// off a live advisory lock. (The e2e suite proves this via a
/// release-then-reprobe; this pins it at the unit level too.)
#[tokio::test]
async fn run_reports_free_when_stale_lock_file_present_but_not_held() {
let dir = tempfile::tempdir().unwrap();
let socket_dir = dir.path().join(".socket");
std::fs::create_dir_all(&socket_dir).unwrap();
// Leftover file, but nobody holds the OS lock.
std::fs::write(socket_dir.join("apply.lock"), b"").unwrap();
let code = run(args_in(dir.path(), false)).await;
assert_eq!(code, 0, "an unheld leftover lock file must read as free");
assert!(
socket_dir.join("apply.lock").is_file(),
"a plain (no --release) probe must not delete the file"
);
}
/// Active holder (via core `acquire`) → `unlock` reports
/// `held`, exits 1, and the file remains on disk.
#[tokio::test]
async fn run_reports_held_when_lock_actively_held() {
let dir = tempfile::tempdir().unwrap();
let socket_dir = dir.path().join(".socket");
std::fs::create_dir_all(&socket_dir).unwrap();
// Hold the lock for the duration of this test. `_guard` is
// bound so its drop doesn't fire until function return.
let _guard = core_acquire(&socket_dir, Duration::ZERO).unwrap();
let code = run(args_in(dir.path(), false)).await;
assert_eq!(code, 1);
assert!(socket_dir.join("apply.lock").is_file());
}
/// `--release` against a free lock with a leftover file removes
/// the file.
#[tokio::test]
async fn run_deletes_lock_file_when_release_and_free() {
let dir = tempfile::tempdir().unwrap();
let socket_dir = dir.path().join(".socket");
std::fs::create_dir_all(&socket_dir).unwrap();
std::fs::write(socket_dir.join("apply.lock"), b"").unwrap();
assert!(socket_dir.join("apply.lock").is_file());
let code = run(args_in(dir.path(), true)).await;
assert_eq!(code, 0);
assert!(
!socket_dir.join("apply.lock").exists(),
"--release should have deleted the file"
);
}
/// `--release` against a clean `.socket/` (no pre-existing lock
/// file) succeeds, and does not leave behind the file that the
/// probe's `acquire` created on demand. Guards the regression
/// where the probe-created file masqueraded as a released
/// leftover.
#[tokio::test]
async fn run_release_cleans_up_probe_created_file() {
let dir = tempfile::tempdir().unwrap();
let socket_dir = dir.path().join(".socket");
std::fs::create_dir_all(&socket_dir).unwrap();
assert!(!socket_dir.join("apply.lock").exists());
let code = run(args_in(dir.path(), true)).await;
assert_eq!(code, 0);
assert!(
!socket_dir.join("apply.lock").exists(),
"--release must not leave a probe-created lock file behind"
);
}
/// `--release` against a stale (unheld) leftover removes it and
/// exits 0 — the recovery path. Distinct from
/// `run_deletes_lock_file_when_release_and_free` only in intent
/// (post-crash leftover), but kept as a named guard so the
/// stale-file recovery contract is explicit.
#[tokio::test]
async fn run_release_removes_stale_unheld_lock_file() {
let dir = tempfile::tempdir().unwrap();
let socket_dir = dir.path().join(".socket");
std::fs::create_dir_all(&socket_dir).unwrap();
std::fs::write(socket_dir.join("apply.lock"), b"crashed-run-leftover").unwrap();
let code = run(args_in(dir.path(), true)).await;
assert_eq!(code, 0);
assert!(
!socket_dir.join("apply.lock").exists(),
"--release must remove an unheld stale lock file"
);
}
/// `--release --dry-run` previews the release without performing
/// it: the pre-existing leftover file must survive. Regression
/// guard: `run` never read `common.dry_run`, so the global
/// `--dry-run` ("Preview, no mutations") flag was silently ignored
/// and the file was deleted anyway.
#[tokio::test]
async fn run_release_dry_run_keeps_leftover_lock_file() {
let dir = tempfile::tempdir().unwrap();
let socket_dir = dir.path().join(".socket");
std::fs::create_dir_all(&socket_dir).unwrap();
std::fs::write(socket_dir.join("apply.lock"), b"crashed-run-leftover").unwrap();
let mut args = args_in(dir.path(), true);
args.common.dry_run = true;
let code = run(args).await;
assert_eq!(code, 0);
assert!(
socket_dir.join("apply.lock").is_file(),
"--dry-run must not delete the leftover lock file"
);
}
/// `--release --dry-run` against a clean `.socket/` must not leave
/// the probe-created file behind — a dry run may not *add* state
/// either. Companion to `run_release_cleans_up_probe_created_file`.
#[tokio::test]
async fn run_release_dry_run_leaves_no_probe_created_file() {
let dir = tempfile::tempdir().unwrap();
let socket_dir = dir.path().join(".socket");
std::fs::create_dir_all(&socket_dir).unwrap();
assert!(!socket_dir.join("apply.lock").exists());
let mut args = args_in(dir.path(), true);
args.common.dry_run = true;
let code = run(args).await;
assert_eq!(code, 0);
assert!(
!socket_dir.join("apply.lock").exists(),
"--release --dry-run must not leave a probe-created lock file behind"
);
}
/// `--release` against a HELD lock refuses (exit 1), file stays.
#[tokio::test]
async fn run_refuses_release_when_held() {
let dir = tempfile::tempdir().unwrap();
let socket_dir = dir.path().join(".socket");
std::fs::create_dir_all(&socket_dir).unwrap();
let _guard = core_acquire(&socket_dir, Duration::ZERO).unwrap();
let code = run(args_in(dir.path(), true)).await;
assert_eq!(code, 1);
assert!(
socket_dir.join("apply.lock").is_file(),
"lock file should still exist — --release must refuse when held"
);
}
}