use anyhow::{Context, Result};
use std::os::windows::process::CommandExt as _;
use std::fs;
use std::path::PathBuf;
use std::process;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{debug, info, warn};
const LOCK_EXPIRY_SECONDS: u64 = 30;
fn lock_path() -> PathBuf {
crate::config::config_dir().join("freecycle.lock")
}
pub struct ProcessLock {
path: PathBuf,
}
impl ProcessLock {
pub fn acquire() -> Result<Option<Self>> {
let path = lock_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("Failed to create lock directory: {}", parent.display())
})?;
}
if path.exists() {
match fs::read_to_string(&path) {
Ok(contents) => {
if let Some((old_pid, timestamp)) = parse_lock(&contents) {
let now = current_timestamp();
let age = now.saturating_sub(timestamp);
if age < LOCK_EXPIRY_SECONDS {
debug!(
"Lock is held (age: {}s, expires in {}s)",
age,
LOCK_EXPIRY_SECONDS - age
);
return Ok(None);
}
info!("Stale lock detected (age: {}s). Taking over.", age);
if let Some(pid) = old_pid {
kill_old_process(pid);
}
} else {
warn!("Corrupted lockfile. Removing and re-acquiring.");
}
}
Err(e) => {
warn!("Could not read lockfile: {}. Removing and re-acquiring.", e);
}
}
let _ = fs::remove_file(&path);
}
write_lock(&path)?;
Ok(Some(Self { path }))
}
pub fn refresh(&self) -> Result<()> {
write_lock(&self.path)
}
}
impl Drop for ProcessLock {
fn drop(&mut self) {
if let Err(e) = fs::remove_file(&self.path) {
warn!("Failed to remove lockfile on drop: {}", e);
} else {
debug!("Process lock released");
}
}
}
fn parse_lock(contents: &str) -> Option<(Option<u32>, u64)> {
let mut lines = contents.trim().lines();
let first = lines.next()?.trim();
if let Some(second) = lines.next() {
let pid: u32 = first.parse().ok()?;
let timestamp: u64 = second.trim().parse().ok()?;
Some((Some(pid), timestamp))
} else {
let timestamp: u64 = first.parse().ok()?;
Some((None, timestamp))
}
}
fn kill_old_process(pid: u32) {
if pid == process::id() {
return;
}
info!("Killing stale FreeCycle process (PID {}) to free port", pid);
let mut sys = sysinfo::System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, false);
if sys.process(sysinfo::Pid::from_u32(pid)).is_none() {
debug!("Old process PID {} no longer exists, skipping kill", pid);
return;
}
const CREATE_NO_WINDOW: u32 = 0x08000000;
match std::process::Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output()
{
Ok(output) if output.status.success() => {
info!("Killed stale FreeCycle process (PID {})", pid);
std::thread::sleep(std::time::Duration::from_secs(2));
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("taskkill failed for PID {}: {}", pid, stderr.trim());
}
Err(e) => {
warn!("Failed to run taskkill for PID {}: {}", pid, e);
}
}
}
fn write_lock(path: &PathBuf) -> Result<()> {
let pid = process::id();
let timestamp = current_timestamp();
let content = format!("{}\n{}", pid, timestamp);
fs::write(path, &content)
.with_context(|| format!("Failed to write lockfile: {}", path.display()))?;
Ok(())
}
fn current_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_current_timestamp_is_reasonable() {
let ts = current_timestamp();
assert!(ts > 1_704_067_200);
}
#[test]
fn test_lock_expiry_constant() {
assert_eq!(LOCK_EXPIRY_SECONDS, 30);
}
#[test]
fn test_parse_lock_new_format() {
let contents = "12345\n1704067200\n";
let result = parse_lock(contents);
assert_eq!(result, Some((Some(12345), 1704067200)));
}
#[test]
fn test_parse_lock_legacy_format() {
let contents = "1704067200\n";
let result = parse_lock(contents);
assert_eq!(result, Some((None, 1704067200)));
}
#[test]
fn test_parse_lock_corrupted() {
assert_eq!(parse_lock("not a number"), None);
assert_eq!(parse_lock(""), None);
assert_eq!(parse_lock("abc\n123"), None);
}
}