use serde::{Deserialize, Serialize};
use super::discovery::lock_file_path;
pub const LOCK_PRODUCT: &str = "trusty-mpm";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DaemonLock {
pub product: String,
pub pid: u32,
pub addr: String,
#[serde(default)]
pub started_at: String,
}
pub fn parse_lock(text: &str) -> Option<DaemonLock> {
let lock: DaemonLock = toml::from_str(text).ok()?;
(lock.product == LOCK_PRODUCT).then_some(lock)
}
pub fn render_lock(lock: &DaemonLock) -> String {
toml::to_string(lock).unwrap_or_else(|e| {
tracing::warn!("failed to encode daemon lock: {e}");
String::new()
})
}
pub fn pid_alive(pid: u32) -> bool {
#[cfg(unix)]
{
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
}
#[cfg(not(unix))]
{
let _ = pid;
true
}
}
pub fn read_lock_at(path: &std::path::Path) -> Option<DaemonLock> {
let lock = parse_lock(&std::fs::read_to_string(path).ok()?)?;
if pid_alive(lock.pid) {
return Some(lock);
}
let _ = std::fs::remove_file(path);
None
}
pub fn read_lock() -> Option<DaemonLock> {
read_lock_at(&lock_file_path())
}
pub fn write_lock_at(path: &std::path::Path, addr: &str) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let lock = DaemonLock {
product: LOCK_PRODUCT.to_string(),
pid: std::process::id(),
addr: addr.to_string(),
started_at: chrono::Utc::now().to_rfc3339(),
};
match std::fs::write(path, render_lock(&lock)) {
Ok(()) => tracing::debug!("lock file written: {}", path.display()),
Err(e) => tracing::warn!("failed to write daemon lock file {}: {e}", path.display()),
}
}
pub fn write_lock(addr: &str) {
write_lock_at(&lock_file_path(), addr);
}
pub fn remove_lock_owned_by_at(path: &std::path::Path, owners: &[u32]) {
let Some(lock) = std::fs::read_to_string(path)
.ok()
.as_deref()
.and_then(parse_lock)
else {
return;
};
if !owners.contains(&lock.pid) {
tracing::debug!(
"leaving daemon lock in place: it names pid {}, not {owners:?}",
lock.pid
);
return;
}
match std::fs::remove_file(path) {
Ok(()) => tracing::debug!("lock file removed"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!("failed to remove lock file: {e}"),
}
}
pub fn remove_lock_owned_by(owners: &[u32]) {
remove_lock_owned_by_at(&lock_file_path(), owners);
}
pub fn parse_lsof_pid(stdout: &str) -> Option<u32> {
stdout.split_whitespace().next()?.parse().ok()
}
pub fn pid_listening_on(port: u16) -> Option<u32> {
let out = std::process::Command::new("lsof")
.args(["-t", "-nP", &format!("-iTCP:{port}"), "-sTCP:LISTEN"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
parse_lsof_pid(&String::from_utf8_lossy(&out.stdout))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> DaemonLock {
DaemonLock {
product: LOCK_PRODUCT.to_string(),
pid: std::process::id(),
addr: "http://127.0.0.1:7880".to_string(),
started_at: "2026-08-18T03:45:07.813744+00:00".to_string(),
}
}
#[test]
fn parse_lock_round_trips() {
let lock = sample();
assert_eq!(parse_lock(&render_lock(&lock)), Some(lock));
}
#[test]
fn parse_lock_rejects_foreign_product() {
let body =
"product = \"some-other-daemon\"\npid = 84890\naddr = \"http://127.0.0.1:7880\"\n";
assert_eq!(parse_lock(body), None);
}
#[test]
fn parse_lock_rejects_missing_product() {
let body = "pid = 84890\naddr = \"http://127.0.0.1:7880\"\n";
assert_eq!(parse_lock(body), None);
}
#[test]
fn parse_lock_rejects_claude_code_lock() {
let body = r#"{
"pid": 84890,
"version": "2.1.225",
"launchTarget": "/Users/masa/.local/share/claude/versions/2.1.225"
}"#;
assert_eq!(parse_lock(body), None);
}
#[test]
fn pid_alive_true_for_self() {
assert!(pid_alive(std::process::id()));
}
#[test]
fn read_lock_returns_written_record() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("daemon.lock");
write_lock_at(&path, "http://127.0.0.1:7880");
let lock = read_lock_at(&path).expect("own lock file must be readable");
assert_eq!(lock.product, LOCK_PRODUCT);
assert_eq!(lock.pid, std::process::id());
assert_eq!(lock.addr, "http://127.0.0.1:7880");
assert!(!lock.started_at.is_empty());
}
#[test]
fn read_lock_rejects_dead_pid() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("daemon.lock");
std::fs::write(
&path,
"product = \"trusty-mpm\"\npid = 4194303\naddr = \"http://127.0.0.1:7880\"\nstarted_at = \"\"\n",
)
.expect("write");
assert_eq!(read_lock_at(&path), None);
assert!(!path.exists(), "a stale record of ours is cleaned up");
}
#[test]
fn read_lock_leaves_foreign_file_in_place() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("daemon.lock");
let body = "product = \"claude-code\"\npid = 4194303\naddr = \"http://127.0.0.1:7880\"\n";
std::fs::write(&path, body).expect("write");
assert_eq!(read_lock_at(&path), None);
assert!(path.exists(), "another product's file must not be deleted");
assert_eq!(std::fs::read_to_string(&path).expect("read"), body);
}
#[test]
fn remove_lock_owned_by_removes_own_record() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("daemon.lock");
write_lock_at(&path, "http://127.0.0.1:7880");
remove_lock_owned_by_at(&path, &[std::process::id()]);
assert!(!path.exists());
}
#[test]
fn remove_lock_owned_by_keeps_newer_daemons_record() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("daemon.lock");
write_lock_at(&path, "http://127.0.0.1:7880");
let outgoing = std::process::id() + 1;
remove_lock_owned_by_at(&path, &[outgoing]);
assert!(path.exists(), "the incoming daemon's record must survive");
assert_eq!(
read_lock_at(&path).expect("still ours").pid,
std::process::id()
);
}
#[test]
fn remove_lock_owned_by_keeps_foreign_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("daemon.lock");
let body = "product = \"claude-code\"\npid = 4194303\n";
std::fs::write(&path, body).expect("write");
remove_lock_owned_by_at(&path, &[4194303]);
assert!(
path.exists(),
"a PID match is not ownership without the magic"
);
}
#[test]
fn parse_lsof_pid_reads_first() {
assert_eq!(parse_lsof_pid("48689\n48690\n"), Some(48689));
}
#[test]
fn parse_lsof_pid_empty() {
assert_eq!(parse_lsof_pid(""), None);
assert_eq!(parse_lsof_pid("\n \n"), None);
}
#[test]
fn parse_lsof_pid_ignores_garbage() {
assert_eq!(parse_lsof_pid("lsof: not found"), None);
}
}