use crate::config::Config;
use crate::paths::Paths;
use crate::util::Level;
use crate::{seed, session, 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 }
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 idle = interval("LOOOP_INTERVAL", &cfg, "interval", 60);
let busy = interval("LOOOP_BUSY_INTERVAL", &cfg, "busy_interval", idle);
let active = interval("LOOOP_ACTIVE_INTERVAL", &cfg, "active_interval", idle);
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));
};
let runner_name = cfg.default_runner().unwrap_or_else(|| "?".into());
util::event(
Level::Ok,
"pulse.start",
&format!("pulse started · idle {idle}s / busy {busy}s · runner {runner_name}"),
&[
("idle", serde_json::json!(idle)),
("busy", serde_json::json!(busy)),
("active", serde_json::json!(active)),
("runner", serde_json::json!(runner_name)),
],
);
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()),
)],
);
}
let mut force = false;
loop {
let outcome = tick::tick(paths, force);
force = false;
let (mut want, mut reason) = if outcome.acted {
(busy, "busy")
} else if session::any_worker_alive(paths) {
(active, "active")
} else {
(idle, "idle")
};
if let Some(req) = outcome.next_interval_s {
let req = req.clamp(5, 3600);
util::event(
Level::Info,
"cadence",
&format!("AI cadence override: next beat in {req}s (default {want}s)"),
&[
("secs", serde_json::json!(req)),
("default", serde_json::json!(want)),
],
);
want = req;
reason = "override";
force = true;
}
util::event(
Level::Info,
"sleep",
&format!("next beat in {want}s ({reason})"),
&[
("secs", serde_json::json!(want)),
("reason", serde_json::json!(reason)),
],
);
std::thread::sleep(Duration::from_secs(want));
}
}
#[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);
}
}