use crate::config::Config;
use crate::paths::Paths;
use crate::util::Level;
use crate::{seed, tick, util};
use anyhow::Result;
use std::fs;
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
pub(crate) fn session_ttl_secs(paths: &Paths) -> u64 {
const DEFAULT: u64 = 3 * 24 * 60 * 60; if let Ok(v) = std::env::var("LOOOP_SESSION_TTL")
&& let Ok(n) = v.trim().parse::<u64>()
{
return n;
}
Config::load(paths)
.ok()
.and_then(|c| {
c.root
.get("session_ttl")
.and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64)))
})
.unwrap_or(DEFAULT)
}
fn interval(env: &str, cfg: &Config, key: &str, fallback: u64) -> u64 {
if let Ok(v) = std::env::var(env)
&& let Ok(n) = v.trim().parse::<u64>()
{
return n;
}
cfg.root
.get(key)
.and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64)))
.unwrap_or(fallback)
}
#[cfg(unix)]
fn try_flock(f: &std::fs::File) -> bool {
use std::os::unix::io::AsRawFd;
const LOCK_EX: i32 = 2;
const LOCK_NB: i32 = 4;
unsafe extern "C" {
fn flock(fd: i32, op: i32) -> i32;
}
unsafe { flock(f.as_raw_fd(), LOCK_EX | LOCK_NB) == 0 }
}
#[cfg(not(unix))]
fn try_flock(_f: &std::fs::File) -> bool {
true }
#[allow(dead_code)]
pub(crate) fn pulse_running(paths: &Paths) -> bool {
let Ok(f) = std::fs::File::open(paths.lock().join("lock")) else {
return false;
};
!try_flock(&f)
}
struct LockGuard {
path: PathBuf,
_file: std::fs::File,
}
impl Drop for LockGuard {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn acquire_lock(paths: &Paths) -> Option<LockGuard> {
let dir = paths.lock();
let _ = fs::create_dir_all(&dir);
let file = fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(dir.join("lock"))
.ok()?;
if !try_flock(&file) {
return None; }
let _ = fs::write(dir.join("pid"), format!("{}\n", std::process::id()));
Some(LockGuard {
path: dir,
_file: file,
})
}
pub fn cmd_run(paths: &Paths) -> Result<ExitCode> {
seed::ensure_dirs(paths)?;
let cfg = Config::load(paths)?;
let beat = interval("LOOOP_INTERVAL", &cfg, "interval", 60);
let Some(_guard) = acquire_lock(paths) else {
let oldpid = fs::read_to_string(paths.lock().join("pid")).unwrap_or_default();
eprintln!("looop: already running (pid {})", oldpid.trim());
return Ok(ExitCode::from(1));
};
util::event(
Level::Ok,
"pulse.start",
&format!("pulse started · sensing every {beat}s (root agent watches via `looop _ wait`)"),
&[("interval", serde_json::json!(beat))],
);
if !paths.default_profile {
util::event(
Level::Info,
"pulse.profile",
&format!(
"this profile's sessions live under {d} (LOOOP_DATA_DIR={d} looop ls)",
d = paths.data_dir.display()
),
&[(
"data_dir",
serde_json::json!(paths.data_dir.display().to_string()),
)],
);
}
loop {
let _ = tick::sense(paths);
std::thread::sleep(Duration::from_secs(beat));
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[test]
fn lock_is_exclusive_and_self_heals_after_release() {
let p = Paths::temp();
assert!(!pulse_running(&p), "no pulse before any acquire");
let g = acquire_lock(&p).expect("first acquire succeeds");
assert!(
acquire_lock(&p).is_none(),
"second acquire blocked while held"
);
assert!(
pulse_running(&p),
"pulse_running true while the lock is held"
);
drop(g);
assert!(!pulse_running(&p), "not running once released");
let g2 = acquire_lock(&p).expect("re-acquire after release");
drop(g2);
}
#[test]
fn stale_lock_dir_is_not_mistaken_for_a_live_pulse() {
let p = Paths::temp();
let dir = p.lock();
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("lock"), b"").unwrap();
std::fs::write(dir.join("pid"), b"999999\n").unwrap();
assert!(
!pulse_running(&p),
"a leftover lock dir is not a running pulse"
);
let g = acquire_lock(&p).expect("acquire over a stale lock dir");
drop(g);
}
}