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 = held_message(timeout);
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 held_message(timeout: Duration) -> String {
if timeout > Duration::ZERO {
format!(
"another socket-patch process is operating in this directory (waited {})",
fmt_duration(timeout)
)
} else {
"another socket-patch process is operating in this directory".to_string()
}
}
fn fmt_duration(d: Duration) -> String {
if d.subsec_nanos() == 0 {
format!("{}s", d.as_secs())
} else {
format!("{}ms", d.as_millis())
}
}
fn error_envelope(command: Command, dry_run: bool, code: &str, message: &str) -> Envelope {
let mut env = Envelope::new(command);
env.dry_run = dry_run;
env.mark_error(EnvelopeError::new(code, message));
env
}
fn emit(
command: Command,
json: bool,
silent: bool,
dry_run: bool,
code: &str,
message: &str,
hint_dir: Option<&Path>,
) {
if json {
println!("{}", error_envelope(command, dry_run, code, message).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 held_message_reports_whole_seconds() {
assert_eq!(
held_message(Duration::from_secs(5)),
"another socket-patch process is operating in this directory (waited 5s)"
);
}
#[test]
fn held_message_does_not_truncate_sub_second_to_zero() {
let msg = held_message(Duration::from_millis(250));
assert!(msg.contains("250ms"), "expected ms rendering, got: {msg}");
assert!(
!msg.contains("0s"),
"sub-second budget must not collapse to 0s: {msg}"
);
}
#[test]
fn held_message_zero_timeout_omits_waited_clause() {
let msg = held_message(Duration::ZERO);
assert!(!msg.contains("waited"), "zero budget should not claim a wait: {msg}");
}
#[test]
fn error_envelope_has_stable_lock_held_shape() {
let env = error_envelope(Command::Apply, false, "lock_held", "held by another run");
let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
assert_eq!(v["command"], "apply");
assert_eq!(v["status"], "error");
assert_eq!(v["dryRun"], false);
assert_eq!(v["error"]["code"], "lock_held");
assert_eq!(v["error"]["message"], "held by another run");
assert_eq!(v["events"].as_array().unwrap().len(), 0);
}
#[test]
fn error_envelope_propagates_dry_run_and_command() {
let env = error_envelope(Command::Rollback, true, "lock_io", "open failed");
let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
assert_eq!(v["command"], "rollback");
assert_eq!(v["dryRun"], true);
assert_eq!(v["error"]["code"], "lock_io");
}
#[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"
);
}
}