feat(tui): implement status bar with connection and turn state indicators feat(tui): create workflow panel for agent status and progress visualization feat(web): introduce web frontend interface with static file serving feat(ws): add WebSocket interface for real-time communication and session management
155 lines
5.9 KiB
Rust
155 lines
5.9 KiB
Rust
//! PID-file based advisory lock preventing two processes from operating on
|
|
//! the same session directory concurrently.
|
|
//!
|
|
//! # Flow
|
|
//!
|
|
//! [`SessionLock::new`] creates a handle → [`SessionLock::try_lock`] attempts
|
|
//! atomic `O_CREAT|O_EXCL` creation. If the lock file already exists, the
|
|
//! owning PID is checked via liveness verification. Stale locks are
|
|
//! overwritten atomically (temp-file + rename + fsync). On [`Drop`],
|
|
//! the lock file is removed automatically.
|
|
//!
|
|
//! # Components
|
|
//!
|
|
//! - `SessionLock` — RAII guard wrapping a lock file path and PID
|
|
//! - `try_lock` — three-phase atomic acquire with stale-lock recovery
|
|
//! - `unlock` / `Drop` — explicit and implicit release
|
|
use std::fs;
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
use tracing;
|
|
|
|
/// A PID-file lock (`<session_dir>/.lock`) tied to the current process,
|
|
/// auto-removed on drop.
|
|
#[derive(Debug)]
|
|
pub struct SessionLock {
|
|
/// Path to the `.lock` file inside the session directory.
|
|
pub(crate) path: PathBuf,
|
|
/// Process ID that holds (or will hold) this lock.
|
|
pub(crate) pid: u32,
|
|
}
|
|
|
|
impl SessionLock {
|
|
/// Construct a lock handle for a session directory (does not acquire
|
|
/// the lock yet — call `try_lock`).
|
|
pub fn new(session_dir: &Path) -> Self {
|
|
SessionLock {
|
|
path: session_dir.join(".lock"),
|
|
pid: std::process::id(),
|
|
}
|
|
}
|
|
|
|
/// Attempt to acquire the session lock using an atomic file creation.
|
|
///
|
|
/// Flow: try `O_CREAT | O_EXCL` via `create_new(true)` → if that
|
|
/// succeeds, the lock is ours — write our PID and return ok. If the
|
|
/// file already exists, read the PID inside it and check whether that
|
|
/// PID is still alive: if the process is still running, fail to acquire;
|
|
/// otherwise the lock is stale — overwrite it with our own PID and succeed.
|
|
///
|
|
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live
|
|
/// process holds it, `Err` on I/O failure.
|
|
pub fn try_lock(&self) -> std::io::Result<bool> {
|
|
// Phase 1: try atomic create. If it succeeds, the lock is ours.
|
|
match fs::OpenOptions::new()
|
|
.create_new(true)
|
|
.write(true)
|
|
.open(&self.path)
|
|
{
|
|
Ok(mut file) => {
|
|
write!(file, "{}", self.pid)?;
|
|
file.sync_all()?;
|
|
tracing::debug!(path = %self.path.display(), pid = self.pid, "session lock acquired");
|
|
return Ok(true);
|
|
}
|
|
Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
|
tracing::debug!(path = %self.path.display(), "session lock already exists, checking staleness");
|
|
// Lock file exists — check if it's stale.
|
|
}
|
|
Err(e) => return Err(e),
|
|
}
|
|
|
|
// Phase 2: lock file exists — check liveness of the owning process.
|
|
let content = fs::read_to_string(&self.path).unwrap_or_default();
|
|
if let Ok(pid) = content.trim().parse::<u32>() {
|
|
if Self::is_alive(pid) {
|
|
tracing::warn!(stale = pid, path = %self.path.display(), "session lock held by live process");
|
|
return Ok(false);
|
|
}
|
|
tracing::debug!(stale = pid, "stale lock detected, overwriting");
|
|
}
|
|
|
|
// Phase 3: stale lock — overwrite it atomically (best-effort).
|
|
// Use a temp file + rename to avoid partial writes corrupting the lock.
|
|
let tmp = self.path.with_extension("lock.tmp");
|
|
{
|
|
let mut tmp_file = fs::OpenOptions::new()
|
|
.create(true)
|
|
.truncate(true)
|
|
.write(true)
|
|
.open(&tmp)?;
|
|
write!(tmp_file, "{}", self.pid)?;
|
|
tmp_file.sync_all()?;
|
|
}
|
|
fs::rename(&tmp, &self.path)?;
|
|
// Sync the parent directory so the rename survives a crash.
|
|
if let Some(parent) = self.path.parent() {
|
|
let _ = fs::File::open(parent).and_then(|d| d.sync_all());
|
|
}
|
|
Ok(true)
|
|
}
|
|
|
|
/// Explicitly release the lock by removing the lock file.
|
|
pub fn unlock(&self) {
|
|
let _ = fs::remove_file(&self.path);
|
|
}
|
|
|
|
/// Check whether a process with the given PID is currently alive.
|
|
///
|
|
/// Uses `kill(pid, 0)` on Unix via the `nix` or `libc` crate in production;
|
|
/// here we provide a best-effort check using the process table.
|
|
/// On non-Unix platforms this always returns `true` (conservative).
|
|
fn is_alive(pid: u32) -> bool {
|
|
// On Unix, signal 0 checks process existence without sending a signal.
|
|
#[cfg(unix)]
|
|
{
|
|
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
|
|
// whether the process exists and the caller has permission to signal it.
|
|
// The integer argument is a PID validated by `try_lock`.
|
|
let pid_signed: i32 = match pid.try_into() {
|
|
Ok(p) => p,
|
|
Err(_) => return false,
|
|
};
|
|
if unsafe { libc::kill(pid_signed, 0) != 0 } {
|
|
return false;
|
|
}
|
|
// Extra check: verify the PID belongs to a zesdex process via
|
|
// /proc/<pid>/exe to mitigate the PID-reuse race.
|
|
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
|
|
if let Ok(target) = std::fs::read_link(&proc_exe) {
|
|
if let Ok(exe) = std::env::current_exe() {
|
|
if target != exe {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
{
|
|
// Fallback: always assume alive (conservative).
|
|
let _ = pid;
|
|
true
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for SessionLock {
|
|
/// Release the lock automatically when the guard goes out of scope,
|
|
/// so an ungracefully-exited process doesn't leave a dangling lock.
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_file(&self.path);
|
|
}
|
|
}
|