#![cfg_attr(target_arch = "wasm32", allow(dead_code))]
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
#[cfg(not(target_arch = "wasm32"))]
mod imp {
use super::{AcquireConfig, HolderInfo, Lock, LockInner, format_iso8601, parse_iso8601};
use fs4::FileExt;
use std::fs::OpenOptions;
use std::io::{Read, Write};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant, SystemTime};
pub fn default_path() -> std::path::PathBuf {
std::env::temp_dir().join("zenbench-exclusive.lock")
}
const WAIT_MESSAGE_FALLBACK: Duration = Duration::from_secs(15);
pub(super) fn info_path(lock_path: &Path) -> std::path::PathBuf {
let mut p = lock_path.as_os_str().to_owned();
p.push(".info");
std::path::PathBuf::from(p)
}
pub fn acquire(cfg: AcquireConfig) -> std::io::Result<Lock> {
let path = cfg.path.clone().unwrap_or_else(default_path);
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)?;
let waited = match FileExt::try_lock(&file) {
Ok(()) => false,
Err(fs4::TryLockError::WouldBlock) => {
let interval = if cfg.waiting_interval.is_zero() {
WAIT_MESSAGE_FALLBACK
} else {
cfg.waiting_interval
};
wait_with_messages(&file, &path, &cfg, interval)?;
true
}
Err(fs4::TryLockError::Error(e)) => return Err(e),
};
let now = SystemTime::now();
let info = HolderInfo {
pid: std::process::id(),
hostname: hostname(),
project: cfg.project.clone(),
binary: cfg.binary.clone(),
benchmark: cfg.benchmark.clone(),
start: now,
heartbeat: now,
eta: cfg.estimated_duration.map(|d| now + d),
activity: cfg.activity.clone(),
};
write_holder(&path, &info)?;
if waited && !cfg.quiet {
eprintln!(
"[zenbench] lock acquired after waiting; running {}/{}",
info.project, info.benchmark
);
}
let inner = Arc::new(Mutex::new(LockInner {
file,
info,
path: path.clone(),
}));
let stop = Arc::new(AtomicBool::new(false));
let heartbeat_period = if cfg.heartbeat.is_zero() {
Duration::from_secs(5)
} else {
cfg.heartbeat
};
let hb_inner = Arc::clone(&inner);
let hb_stop = Arc::clone(&stop);
let handle = thread::Builder::new()
.name("zenbench-exclusive-heartbeat".into())
.spawn(move || heartbeat_loop(hb_inner, hb_stop, heartbeat_period))
.ok();
Ok(Lock {
inner: Some(inner),
stop,
heartbeat_thread: handle,
path,
})
}
pub fn try_acquire(cfg: AcquireConfig) -> std::io::Result<Result<Lock, Option<HolderInfo>>> {
let path = cfg.path.clone().unwrap_or_else(default_path);
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)?;
match FileExt::try_lock(&file) {
Ok(()) => {
let now = SystemTime::now();
let info = HolderInfo {
pid: std::process::id(),
hostname: hostname(),
project: cfg.project.clone(),
binary: cfg.binary.clone(),
benchmark: cfg.benchmark.clone(),
start: now,
heartbeat: now,
eta: cfg.estimated_duration.map(|d| now + d),
activity: cfg.activity.clone(),
};
write_holder(&path, &info)?;
let inner = Arc::new(Mutex::new(LockInner {
file,
info,
path: path.clone(),
}));
let stop = Arc::new(AtomicBool::new(false));
let heartbeat_period = if cfg.heartbeat.is_zero() {
Duration::from_secs(5)
} else {
cfg.heartbeat
};
let hb_inner = Arc::clone(&inner);
let hb_stop = Arc::clone(&stop);
let handle = thread::Builder::new()
.name("zenbench-exclusive-heartbeat".into())
.spawn(move || heartbeat_loop(hb_inner, hb_stop, heartbeat_period))
.ok();
Ok(Ok(Lock {
inner: Some(inner),
stop,
heartbeat_thread: handle,
path,
}))
}
Err(fs4::TryLockError::WouldBlock) => {
let info = read_holder(&path).ok().flatten();
Ok(Err(info))
}
Err(fs4::TryLockError::Error(e)) => Err(e),
}
}
pub fn peek(path: &Path) -> std::io::Result<Option<HolderInfo>> {
if !path.exists() {
return Ok(None);
}
read_holder(path)
}
pub fn drop_lock(lock: &mut Lock) {
lock.stop.store(true, Ordering::SeqCst);
if let Some(handle) = lock.heartbeat_thread.take() {
let _ = handle.join();
}
if let Some(inner) = lock.inner.take() {
if let Ok(inner) = Arc::try_unwrap(inner) {
let inner = inner.into_inner().unwrap_or_else(|p| p.into_inner());
let _ = FileExt::unlock(&inner.file);
drop(inner.file);
}
}
}
pub fn update_benchmark(lock: &Lock, benchmark: &str) {
if let Some(inner_arc) = lock.inner.as_ref() {
if let Ok(mut inner) = inner_arc.lock() {
inner.info.benchmark = benchmark.to_string();
let path = inner.path.clone();
let _ = write_holder(&path, &inner.info);
}
}
}
pub fn update_eta(lock: &Lock, eta: SystemTime) {
if let Some(inner_arc) = lock.inner.as_ref() {
if let Ok(mut inner) = inner_arc.lock() {
inner.info.eta = Some(eta);
let path = inner.path.clone();
let _ = write_holder(&path, &inner.info);
}
}
}
pub fn read_info(lock: &Lock) -> Option<HolderInfo> {
lock.inner
.as_ref()
.and_then(|i| i.lock().ok().map(|g| g.info.clone()))
}
fn heartbeat_loop(inner: Arc<Mutex<LockInner>>, stop: Arc<AtomicBool>, period: Duration) {
let slice = Duration::from_millis(100);
loop {
let mut waited = Duration::ZERO;
while waited < period {
if stop.load(Ordering::SeqCst) {
return;
}
thread::sleep(slice);
waited += slice;
}
if stop.load(Ordering::SeqCst) {
return;
}
if let Ok(mut g) = inner.lock() {
g.info.heartbeat = SystemTime::now();
let path = g.path.clone();
let _ = write_holder(&path, &g.info);
}
}
}
fn wait_with_messages(
file: &std::fs::File,
path: &Path,
cfg: &AcquireConfig,
message_interval: Duration,
) -> std::io::Result<()> {
let deadline = cfg.timeout.map(|t| Instant::now() + t);
let mut next_message = Instant::now();
let poll = Duration::from_millis(200);
loop {
if Instant::now() >= next_message && !cfg.quiet {
if let Ok(Some(info)) = read_holder(path) {
eprintln!("[zenbench] {}", info.waiting_message(SystemTime::now()));
} else {
eprintln!(
"[zenbench] waiting on zenbench-exclusive lock at {} (no holder info available)",
path.display()
);
}
next_message = Instant::now() + message_interval;
}
match FileExt::try_lock(file) {
Ok(()) => return Ok(()),
Err(fs4::TryLockError::WouldBlock) => {}
Err(fs4::TryLockError::Error(e)) => return Err(e),
}
if let Some(d) = deadline {
if Instant::now() >= d {
return Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"exclusive bench lock acquire timed out",
));
}
}
thread::sleep(poll);
}
}
fn write_holder(lock_path: &Path, info: &HolderInfo) -> std::io::Result<()> {
let info_path = info_path(lock_path);
let tmp_path = {
let mut p = info_path.as_os_str().to_owned();
p.push(".tmp");
std::path::PathBuf::from(p)
};
let mut body = String::with_capacity(512);
body.push_str("zenbench-exclusive v1\n");
body.push_str(&format!("pid={}\n", info.pid));
body.push_str(&format!("hostname={}\n", info.hostname));
body.push_str(&format!("project={}\n", info.project));
body.push_str(&format!("binary={}\n", info.binary));
body.push_str(&format!("benchmark={}\n", info.benchmark));
body.push_str(&format!("activity={}\n", info.activity));
body.push_str(&format!("start={}\n", format_iso8601(info.start)));
body.push_str(&format!("heartbeat={}\n", format_iso8601(info.heartbeat)));
if let Some(eta) = info.eta {
body.push_str(&format!("eta={}\n", format_iso8601(eta)));
} else {
body.push_str("eta=\n");
}
body.push_str("eof=1\n");
{
let mut f = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&tmp_path)?;
f.write_all(body.as_bytes())?;
f.flush()?;
}
std::fs::rename(&tmp_path, &info_path)?;
Ok(())
}
pub(super) fn read_holder(lock_path: &Path) -> std::io::Result<Option<HolderInfo>> {
let info_path = info_path(lock_path);
let mut file = match OpenOptions::new().read(true).open(&info_path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let mut buf = String::new();
file.read_to_string(&mut buf)?;
if !buf.contains("eof=1") {
return Ok(None);
}
let mut info = HolderInfo {
pid: 0,
hostname: String::new(),
project: String::new(),
binary: String::new(),
benchmark: String::new(),
activity: String::new(),
start: SystemTime::UNIX_EPOCH,
heartbeat: SystemTime::UNIX_EPOCH,
eta: None,
};
let mut version_seen = false;
for line in buf.lines() {
let line = line.trim_end();
if line == "zenbench-exclusive v1" {
version_seen = true;
continue;
}
let Some((k, v)) = line.split_once('=') else {
continue;
};
match k {
"pid" => info.pid = v.parse().unwrap_or(0),
"hostname" => info.hostname = v.to_string(),
"project" => info.project = v.to_string(),
"binary" => info.binary = v.to_string(),
"benchmark" => info.benchmark = v.to_string(),
"activity" => info.activity = v.to_string(),
"start" => {
if let Some(t) = parse_iso8601(v) {
info.start = t;
}
}
"heartbeat" => {
if let Some(t) = parse_iso8601(v) {
info.heartbeat = t;
}
}
"eta" if !v.is_empty() => {
info.eta = parse_iso8601(v);
}
_ => {}
}
}
if !version_seen {
return Ok(None);
}
Ok(Some(info))
}
fn hostname() -> String {
sysinfo::System::host_name()
.or_else(|| std::env::var("HOSTNAME").ok())
.or_else(|| std::env::var("COMPUTERNAME").ok())
.unwrap_or_else(|| "unknown".into())
}
}
#[cfg(target_arch = "wasm32")]
mod imp {
use super::{AcquireConfig, HolderInfo, Lock};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
pub fn default_path() -> PathBuf {
PathBuf::from("/zenbench-exclusive.lock")
}
pub fn acquire(_cfg: AcquireConfig) -> std::io::Result<Lock> {
Ok(Lock { _priv: () })
}
pub fn try_acquire(_cfg: AcquireConfig) -> std::io::Result<Result<Lock, Option<HolderInfo>>> {
Ok(Ok(Lock { _priv: () }))
}
pub fn peek(_path: &Path) -> std::io::Result<Option<HolderInfo>> {
Ok(None)
}
pub fn drop_lock(_lock: &mut Lock) {}
pub fn update_benchmark(_lock: &Lock, _benchmark: &str) {}
pub fn update_eta(_lock: &Lock, _eta: SystemTime) {}
pub fn read_info(_lock: &Lock) -> Option<HolderInfo> {
None
}
}
#[derive(Clone, Debug)]
pub struct AcquireConfig {
pub path: Option<PathBuf>,
pub timeout: Option<Duration>,
pub heartbeat: Duration,
pub waiting_interval: Duration,
pub quiet: bool,
pub project: String,
pub binary: String,
pub benchmark: String,
pub activity: String,
pub estimated_duration: Option<Duration>,
}
impl Default for AcquireConfig {
fn default() -> Self {
Self {
path: None,
timeout: None,
heartbeat: Duration::from_secs(5),
waiting_interval: Duration::from_secs(15),
quiet: false,
project: detect_project(),
binary: detect_binary(),
benchmark: String::new(),
activity: String::new(),
estimated_duration: None,
}
}
}
#[derive(Clone, Debug)]
pub struct HolderInfo {
pub pid: u32,
pub hostname: String,
pub project: String,
pub binary: String,
pub benchmark: String,
pub activity: String,
pub start: SystemTime,
pub heartbeat: SystemTime,
pub eta: Option<SystemTime>,
}
impl HolderInfo {
pub fn is_stale(&self, threshold: Duration, now: SystemTime) -> bool {
match now.duration_since(self.heartbeat) {
Ok(d) => d > threshold,
Err(_) => false,
}
}
pub fn waiting_message(&self, now: SystemTime) -> String {
let project = if self.project.is_empty() {
"<unknown project>".to_string()
} else {
self.project.clone()
};
let bench = if self.benchmark.is_empty() {
"<benchmark unset>".to_string()
} else {
self.benchmark.clone()
};
let mut s = format!(
"waiting on exclusive bench lock — {project}/{bench} (pid {pid}",
project = project,
bench = bench,
pid = self.pid,
);
if !self.binary.is_empty() && self.binary != self.project {
s.push_str(&format!(", binary {}", self.binary));
}
if let Ok(d) = now.duration_since(self.start) {
s.push_str(&format!(", running {}", format_duration(d)));
}
if let Some(eta) = self.eta {
match eta.duration_since(now) {
Ok(d) if !d.is_zero() => s.push_str(&format!(", ETA in {}", format_duration(d))),
Ok(_) => s.push_str(", ETA reached"),
Err(_) => s.push_str(", ETA passed"),
}
}
if let Ok(d) = now.duration_since(self.heartbeat) {
if d > Duration::from_secs(15) {
s.push_str(&format!(
", last heartbeat {} ago — possibly hung",
format_duration(d)
));
}
}
s.push(')');
s
}
}
#[cfg(not(target_arch = "wasm32"))]
struct LockInner {
file: std::fs::File,
info: HolderInfo,
path: PathBuf,
}
pub struct Lock {
#[cfg(not(target_arch = "wasm32"))]
inner: Option<std::sync::Arc<std::sync::Mutex<LockInner>>>,
#[cfg(not(target_arch = "wasm32"))]
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
#[cfg(not(target_arch = "wasm32"))]
heartbeat_thread: Option<std::thread::JoinHandle<()>>,
#[cfg(not(target_arch = "wasm32"))]
path: PathBuf,
#[cfg(target_arch = "wasm32")]
_priv: (),
}
impl Lock {
pub fn acquire(cfg: AcquireConfig) -> std::io::Result<Self> {
imp::acquire(cfg)
}
pub fn try_acquire(cfg: AcquireConfig) -> std::io::Result<Result<Self, Option<HolderInfo>>> {
imp::try_acquire(cfg)
}
pub fn peek(path: &Path) -> std::io::Result<Option<HolderInfo>> {
imp::peek(path)
}
pub fn default_path() -> PathBuf {
imp::default_path()
}
pub fn update_benchmark(&self, benchmark: &str) {
imp::update_benchmark(self, benchmark)
}
pub fn update_eta(&self, eta: SystemTime) {
imp::update_eta(self, eta)
}
pub fn info(&self) -> Option<HolderInfo> {
imp::read_info(self)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for Lock {
fn drop(&mut self) {
imp::drop_lock(self);
}
}
fn detect_project() -> String {
std::env::var("CARGO_PKG_NAME").unwrap_or_else(|_| String::new())
}
fn detect_binary() -> String {
std::env::var("CARGO_BIN_NAME")
.or_else(|_| std::env::var("CARGO_CRATE_NAME"))
.ok()
.or_else(|| {
std::env::current_exe()
.ok()
.and_then(|p| p.file_name().map(|s| s.to_string_lossy().into_owned()))
})
.unwrap_or_default()
}
fn format_duration(d: Duration) -> String {
let secs = d.as_secs();
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m{:02}s", secs / 60, secs % 60)
} else {
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
}
}
fn format_iso8601(t: SystemTime) -> String {
let secs = t
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
seconds_to_iso8601(secs)
}
fn parse_iso8601(s: &str) -> Option<SystemTime> {
let bytes = s.as_bytes();
if bytes.len() != 20
|| bytes[4] != b'-'
|| bytes[7] != b'-'
|| bytes[10] != b'T'
|| bytes[13] != b':'
|| bytes[16] != b':'
|| bytes[19] != b'Z'
{
return None;
}
let parse = |start: usize, end: usize| -> Option<u64> {
std::str::from_utf8(&bytes[start..end])
.ok()?
.parse::<u64>()
.ok()
};
let y = parse(0, 4)?;
let mo = parse(5, 7)?;
let d = parse(8, 10)?;
let h = parse(11, 13)?;
let mi = parse(14, 16)?;
let se = parse(17, 19)?;
let secs = iso_to_secs(y, mo, d, h, mi, se)?;
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(secs))
}
fn seconds_to_iso8601(secs: u64) -> String {
let days = secs / 86400;
let rem = secs % 86400;
let hours = rem / 3600;
let minutes = (rem % 3600) / 60;
let seconds = rem % 60;
let mut y: u64 = 1970;
let mut d = days;
loop {
let yd = if is_leap(y) { 366 } else { 365 };
if d < yd {
break;
}
d -= yd;
y += 1;
}
let month_days: [u64; 12] = if is_leap(y) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut m = 1u64;
for &md in &month_days {
if d < md {
break;
}
d -= md;
m += 1;
}
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
y,
m,
d + 1,
hours,
minutes,
seconds
)
}
fn iso_to_secs(y: u64, mo: u64, d: u64, h: u64, mi: u64, se: u64) -> Option<u64> {
if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h >= 24 || mi >= 60 || se >= 60 {
return None;
}
let mut days: u64 = 0;
for yy in 1970..y {
days += if is_leap(yy) { 366 } else { 365 };
}
let month_days: [u64; 12] = if is_leap(y) {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
for &md in month_days.iter().take((mo - 1) as usize) {
days += md;
}
days += d - 1;
Some(days * 86400 + h * 3600 + mi * 60 + se)
}
fn is_leap(y: u64) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
fn temp_lock_path(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"zenbench-exclusive-test-{}-{}-{}.lock",
label,
std::process::id(),
std::time::SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_nanos(),
))
}
fn cfg(path: &Path) -> AcquireConfig {
AcquireConfig {
path: Some(path.to_path_buf()),
quiet: true,
project: "zenbench".into(),
binary: "test".into(),
benchmark: "initial".into(),
heartbeat: Duration::from_millis(50),
waiting_interval: Duration::from_millis(50),
..Default::default()
}
}
#[test]
fn acquire_release_roundtrip() {
let p = temp_lock_path("rt");
{
let lock = Lock::acquire(cfg(&p)).expect("acquire");
let info = Lock::peek(&p).expect("peek").expect("peek some");
assert_eq!(info.project, "zenbench");
assert_eq!(info.benchmark, "initial");
drop(lock);
}
let lock2 = Lock::acquire(cfg(&p)).expect("reacquire");
drop(lock2);
let _ = std::fs::remove_file(&p);
}
#[test]
fn try_acquire_returns_holder_info_when_held() {
let p = temp_lock_path("try");
let _held = Lock::acquire(cfg(&p)).expect("acquire 1");
let mut cfg2 = cfg(&p);
cfg2.benchmark = "second".into();
match Lock::try_acquire(cfg2).expect("try_acquire") {
Ok(_) => panic!("expected lock to be held"),
Err(Some(info)) => {
assert_eq!(info.benchmark, "initial");
assert_eq!(info.pid, std::process::id());
}
Err(None) => panic!("expected holder info, got none"),
}
let _ = std::fs::remove_file(&p);
}
#[test]
fn update_benchmark_reflected_in_peek() {
let p = temp_lock_path("upd");
let lock = Lock::acquire(cfg(&p)).expect("acquire");
lock.update_benchmark("phase-2");
let info = Lock::peek(&p).expect("peek").expect("peek some");
assert_eq!(info.benchmark, "phase-2");
drop(lock);
let _ = std::fs::remove_file(&p);
}
#[test]
fn heartbeat_advances() {
let p = temp_lock_path("hb");
let lock = Lock::acquire(cfg(&p)).expect("acquire");
let first = Lock::peek(&p).unwrap().unwrap().heartbeat;
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let later = loop {
std::thread::sleep(Duration::from_millis(200));
let hb = Lock::peek(&p).unwrap().unwrap().heartbeat;
if hb > first || std::time::Instant::now() >= deadline {
break hb;
}
};
assert!(
later > first,
"heartbeat should advance within 10s: first={first:?} later={later:?}"
);
drop(lock);
let _ = std::fs::remove_file(&p);
}
#[test]
fn eta_round_trip() {
let p = temp_lock_path("eta");
let mut c = cfg(&p);
c.estimated_duration = Some(Duration::from_secs(120));
let lock = Lock::acquire(c).expect("acquire");
let info = Lock::peek(&p).unwrap().unwrap();
assert!(info.eta.is_some());
drop(lock);
let _ = std::fs::remove_file(&p);
}
#[test]
fn waiting_message_includes_project_and_eta() {
let now = SystemTime::now();
let info = HolderInfo {
pid: 999,
hostname: "h".into(),
project: "myproj".into(),
binary: "bench".into(),
benchmark: "sort_1k".into(),
activity: "".into(),
start: now - Duration::from_secs(45),
heartbeat: now,
eta: Some(now + Duration::from_secs(180)),
};
let msg = info.waiting_message(now);
assert!(msg.contains("myproj/sort_1k"), "msg: {msg}");
assert!(msg.contains("pid 999"));
assert!(msg.contains("ETA in 3m00s"), "msg: {msg}");
}
#[test]
fn iso_round_trip() {
let now_secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let s = seconds_to_iso8601(now_secs);
let parsed = parse_iso8601(&s).expect("parse");
let parsed_secs = parsed
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
assert_eq!(now_secs, parsed_secs);
}
}