use std::path::Path;
use std::time::Duration;
use socket_patch_core::patch::apply_lock::{acquire, LockError, LockGuard};
use crate::json_envelope::{
Command, Envelope, EnvelopeError, PatchAction, PatchEvent,
};
pub const LOCK_BROKEN_CODE: &str = "lock_broken";
#[derive(Debug)]
pub struct LockAcquired {
pub guard: LockGuard,
pub broke_lock: bool,
}
pub fn acquire_or_emit(
socket_dir: &Path,
command: Command,
json: bool,
silent: bool,
dry_run: bool,
timeout: Duration,
break_lock: bool,
) -> Result<LockAcquired, i32> {
let mut broke_lock = false;
if break_lock {
let path = socket_dir.join("apply.lock");
match std::fs::remove_file(&path) {
Ok(()) => {
broke_lock = true;
if !silent && !json {
eprintln!(
"Warning: --break-lock removed {} before acquisition.",
path.display()
);
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
}
Err(source) => {
let msg = format!(
"failed to remove lock file at {}: {}",
path.display(),
source
);
emit(command, json, silent, dry_run, "lock_break_failed", &msg, None);
return Err(1);
}
}
}
match acquire(socket_dir, timeout) {
Ok(guard) => Ok(LockAcquired { guard, broke_lock }),
Err(LockError::Held) => {
let msg = if timeout > Duration::ZERO {
format!(
"another socket-patch process is operating in this directory (waited {}s)",
timeout.as_secs()
)
} else {
"another socket-patch process is operating in this directory".to_string()
};
emit(
command,
json,
silent,
dry_run,
"lock_held",
&msg,
Some(socket_dir),
);
Err(1)
}
Err(LockError::Io { path, source }) => {
let msg = format!("failed to open lock file at {}: {}", path.display(), source);
emit(command, json, silent, dry_run, "lock_io", &msg, None);
Err(1)
}
}
}
pub fn lock_broken_event(socket_dir: &Path) -> PatchEvent {
PatchEvent::artifact(PatchAction::Skipped).with_reason(
LOCK_BROKEN_CODE,
format!(
"--break-lock removed {}/apply.lock before acquisition",
socket_dir.display()
),
)
}
pub fn record_lock_broken(env: &mut Envelope, socket_dir: &Path) {
env.record(lock_broken_event(socket_dir));
}
fn emit(
command: Command,
json: bool,
silent: bool,
dry_run: bool,
code: &str,
message: &str,
hint_dir: Option<&Path>,
) {
if json {
let mut env = Envelope::new(command);
env.dry_run = dry_run;
env.mark_error(EnvelopeError::new(code, message));
println!("{}", env.to_pretty_json());
} else if !silent {
eprintln!("Error: {message}.");
if hint_dir.is_some() {
eprintln!(
" Run `socket-patch unlock` to inspect, or rerun with --break-lock if you're sure no holder exists."
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn acquire_or_emit_succeeds_on_fresh_dir() {
let dir = tempfile::tempdir().unwrap();
let acquired = acquire_or_emit(
dir.path(),
Command::Apply,
false,
true,
false,
Duration::ZERO,
false,
)
.unwrap();
assert!(!acquired.broke_lock);
drop(acquired.guard);
}
#[test]
fn acquire_or_emit_returns_one_on_contention() {
let dir = tempfile::tempdir().unwrap();
let _first = acquire_or_emit(
dir.path(),
Command::Apply,
false,
true,
false,
Duration::ZERO,
false,
)
.unwrap();
let code = acquire_or_emit(
dir.path(),
Command::Apply,
false,
true,
false,
Duration::ZERO,
false,
)
.unwrap_err();
assert_eq!(code, 1);
}
#[test]
fn acquire_or_emit_returns_one_when_socket_dir_missing() {
let dir = tempfile::tempdir().unwrap();
let code = acquire_or_emit(
&dir.path().join("nope"),
Command::Apply,
false,
true,
false,
Duration::ZERO,
false,
)
.unwrap_err();
assert_eq!(code, 1);
}
#[test]
fn acquire_or_emit_honors_lock_timeout() {
let dir = tempfile::tempdir().unwrap();
let _first = acquire_or_emit(
dir.path(),
Command::Apply,
false,
true,
false,
Duration::ZERO,
false,
)
.unwrap();
let start = std::time::Instant::now();
let code = acquire_or_emit(
dir.path(),
Command::Apply,
false,
true,
false,
Duration::from_millis(250),
false,
)
.unwrap_err();
let elapsed = start.elapsed();
assert_eq!(code, 1);
assert!(
elapsed >= Duration::from_millis(200),
"expected at least 200ms wait, got {:?}",
elapsed
);
}
#[test]
fn acquire_or_emit_break_lock_removes_and_acquires() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("apply.lock"), b"").unwrap();
let acquired = acquire_or_emit(
dir.path(),
Command::Apply,
false,
true,
false,
Duration::ZERO,
true,
)
.unwrap();
assert!(
acquired.broke_lock,
"broke_lock should be true when a lock file existed and was removed"
);
assert!(dir.path().join("apply.lock").is_file());
}
#[test]
fn acquire_or_emit_break_lock_is_noop_when_no_file() {
let dir = tempfile::tempdir().unwrap();
let acquired = acquire_or_emit(
dir.path(),
Command::Apply,
false,
true,
false,
Duration::ZERO,
true,
)
.unwrap();
assert!(
!acquired.broke_lock,
"broke_lock should be false when there was nothing to remove"
);
}
#[test]
fn lock_broken_event_uses_documented_code() {
let dir = tempfile::tempdir().unwrap();
let event = lock_broken_event(dir.path());
let v: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
assert_eq!(v["action"], "skipped");
assert_eq!(v["errorCode"], LOCK_BROKEN_CODE);
assert!(
v.as_object().unwrap().get("purl").is_none(),
"lock_broken is an artifact-level event — no purl"
);
}
}