1use std::collections::HashMap;
21use std::fs::File;
22use std::path::{Path, PathBuf};
23use std::sync::{Mutex, MutexGuard, OnceLock};
24use std::time::{SystemTime, UNIX_EPOCH};
25
26use fs4::fs_std::FileExt as _;
27use serde::{Deserialize, Serialize};
28
29use super::files;
30use crate::error::SessionLockError;
31
32#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct LeaseInfo {
36 pub pid: u32,
38 pub boot_id: String,
40 pub host: String,
42 pub client: String,
44}
45
46#[derive(Clone, Debug, Serialize, Deserialize)]
48struct LeaseMetadata {
49 v: u32,
51 #[serde(flatten)]
52 info: LeaseInfo,
54 #[serde(rename = "startedAt")]
56 started_at: u64,
57}
58
59#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
61pub struct TakeoverRequest {
62 pub v: u32,
64 pub requester: LeaseInfo,
66 pub at: u64,
68}
69
70static SESSION_LOCKS: OnceLock<Mutex<HashMap<PathBuf, LeaseInfo>>> = OnceLock::new();
73static BOOT_ID: OnceLock<String> = OnceLock::new();
75
76fn registry() -> MutexGuard<'static, HashMap<PathBuf, LeaseInfo>> {
77 SESSION_LOCKS
78 .get_or_init(|| Mutex::new(HashMap::new()))
79 .lock()
80 .unwrap_or_else(std::sync::PoisonError::into_inner)
81}
82
83fn now_ms() -> u64 {
84 SystemTime::now()
85 .duration_since(UNIX_EPOCH)
86 .map_or(0, |since| u64::try_from(since.as_millis()).unwrap_or(0))
87}
88
89#[must_use]
91pub fn lock_file_path(session_file: &Path) -> PathBuf {
92 sibling(session_file, ".lock")
93}
94
95#[must_use]
97pub fn lock_metadata_path(session_file: &Path) -> PathBuf {
98 sibling(session_file, ".lock.json")
99}
100
101#[must_use]
103pub fn takeover_path(session_file: &Path) -> PathBuf {
104 sibling(session_file, ".takeover")
105}
106
107fn sibling(session_file: &Path, suffix: &str) -> PathBuf {
108 let mut name = session_file.as_os_str().to_os_string();
109 name.push(suffix);
110 session_file.with_file_name(name)
111}
112
113fn hostname() -> String {
114 if cfg!(target_os = "linux")
115 && let Ok(host) = std::fs::read_to_string("/proc/sys/kernel/hostname")
116 && !host.trim().is_empty()
117 {
118 return host.trim().to_owned();
119 }
120 if cfg!(windows)
121 && let Some(host) = std::env::var_os("COMPUTERNAME")
122 {
123 return host.to_string_lossy().into_owned();
124 }
125 std::env::var_os("HOSTNAME").map_or_else(
126 || "unknown-host".to_owned(),
127 |host| host.to_string_lossy().into_owned(),
128 )
129}
130
131fn boot_id(state_dir: &Path) -> String {
134 if let Ok(boot) = std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
135 && !boot.trim().is_empty()
136 {
137 return boot.trim().to_owned();
138 }
139 let path = state_dir.join("oino.boot-id");
140 if let Ok(existing) = std::fs::read_to_string(&path)
141 && !existing.trim().is_empty()
142 {
143 return existing.trim().to_owned();
144 }
145 let id = uuid::Uuid::new_v4().to_string();
146 let _ = std::fs::create_dir_all(state_dir);
147 let _ = std::fs::write(&path, &id);
148 id
149}
150
151fn current_boot_id() -> Option<String> {
153 BOOT_ID.get().cloned().or_else(|| {
154 std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
155 .ok()
156 .map(|boot| boot.trim().to_owned())
157 .filter(|boot| !boot.is_empty())
158 })
159}
160
161fn pid_alive(pid: u32) -> bool {
165 if pid == 0 {
166 return false;
167 }
168 #[cfg(unix)]
169 {
170 let sent = nix::sys::signal::kill(
172 nix::unistd::Pid::from_raw(i32::try_from(pid).unwrap_or(-1)),
173 None,
174 );
175 !matches!(sent, Err(nix::errno::Errno::ESRCH))
176 }
177 #[cfg(windows)]
178 {
179 std::process::Command::new("tasklist")
181 .args(["/NH", "/FI", &format!("PID eq {pid}")])
182 .output()
183 .map(|output| {
184 output.status.success()
185 && String::from_utf8_lossy(&output.stdout).contains(&pid.to_string())
186 })
187 .unwrap_or(true)
188 }
189 #[cfg(not(any(unix, windows)))]
190 {
191 true
192 }
193}
194
195impl LeaseInfo {
196 #[must_use]
199 pub fn for_process(client: &str, state_dir: &Path) -> Self {
200 let boot_id = BOOT_ID.get_or_init(|| boot_id(state_dir)).clone();
201 Self {
202 pid: std::process::id(),
203 boot_id,
204 host: hostname(),
205 client: client.to_owned(),
206 }
207 }
208
209 #[must_use]
211 pub fn describe(&self) -> String {
212 format!("{} (pid {} on {})", self.client, self.pid, self.host)
213 }
214
215 #[must_use]
219 pub fn dead(&self) -> bool {
220 self.pid != 0
221 && !self.host.is_empty()
222 && self.host == hostname()
223 && current_boot_id().is_some_and(|boot| boot == self.boot_id)
224 && !pid_alive(self.pid)
225 }
226}
227
228fn read_metadata(session_file: &Path) -> LeaseInfo {
229 let fallback = LeaseInfo {
230 pid: 0,
231 boot_id: String::new(),
232 host: String::new(),
233 client: "another Oino process".to_owned(),
234 };
235 std::fs::read_to_string(lock_metadata_path(session_file))
236 .ok()
237 .and_then(|text| serde_json::from_str::<LeaseMetadata>(&text).ok())
238 .map_or(fallback, |metadata| metadata.info)
239}
240
241fn write_metadata(session_file: &Path, holder: &LeaseInfo) {
242 let metadata = LeaseMetadata {
243 v: 1,
244 info: holder.clone(),
245 started_at: now_ms(),
246 };
247 if let Ok(text) = serde_json::to_string(&metadata) {
248 let _ = files::write_atomic(&lock_metadata_path(session_file), &text);
249 }
250}
251
252#[derive(Debug)]
260pub struct SessionLease {
261 session_file: PathBuf,
262 lock_file: PathBuf,
263 file: Option<File>,
264 info: LeaseInfo,
265}
266
267impl SessionLease {
268 pub fn acquire(session_file: &Path, holder: &LeaseInfo) -> Result<Self, SessionLockError> {
279 let session_file = files::resolve_path(session_file);
280 let lock_file = lock_file_path(&session_file);
281 let mut held = registry();
282 if let Some(existing) = held.get(&session_file) {
283 return Err(SessionLockError::HeldBy(existing.clone()));
284 }
285 if !session_file.exists()
286 && let Some(parent) = session_file.parent()
287 {
288 let _ = std::fs::create_dir_all(parent);
291 }
292 let file = std::fs::OpenOptions::new()
293 .read(true)
294 .write(true)
295 .create(true)
296 .truncate(false)
297 .open(&lock_file)
298 .map_err(|source| SessionLockError::Io {
299 path: lock_file.clone(),
300 source,
301 })?;
302 match file.try_lock_exclusive() {
303 Ok(true) => {}
304 Ok(false) => {
305 return Err(SessionLockError::HeldBy(read_metadata(&session_file)));
306 }
307 Err(source) => {
308 return Err(SessionLockError::Io {
309 path: lock_file,
310 source,
311 });
312 }
313 }
314 held.insert(session_file.clone(), holder.clone());
315 drop(held);
316 write_metadata(&session_file, holder);
317 Ok(Self {
318 session_file,
319 lock_file,
320 file: Some(file),
321 info: holder.clone(),
322 })
323 }
324
325 #[must_use]
327 pub fn info(&self) -> &LeaseInfo {
328 &self.info
329 }
330
331 #[must_use]
333 pub fn session_file(&self) -> &Path {
334 &self.session_file
335 }
336
337 pub fn release(self) {
340 }
342}
343
344impl Drop for SessionLease {
345 fn drop(&mut self) {
346 registry().remove(&self.session_file);
347 self.file = None; let _ = std::fs::remove_file(lock_metadata_path(&self.session_file));
349 let _ = std::fs::remove_file(takeover_path(&self.session_file));
350 if !self.session_file.exists() {
351 let _ = std::fs::remove_file(&self.lock_file);
352 }
353 }
354}
355
356pub fn write_takeover_request(
362 session_file: &Path,
363 requester: &LeaseInfo,
364) -> Result<(), SessionLockError> {
365 let request = TakeoverRequest {
366 v: 1,
367 requester: requester.clone(),
368 at: now_ms(),
369 };
370 let text = serde_json::to_string(&request).map_err(|source| SessionLockError::Io {
371 path: takeover_path(session_file),
372 source: std::io::Error::other(source),
373 })?;
374 files::write_atomic(&takeover_path(session_file), &text).map_err(|source| {
375 SessionLockError::Io {
376 path: takeover_path(session_file),
377 source: std::io::Error::other(source.to_string()),
378 }
379 })
380}
381
382#[must_use]
384pub fn read_takeover_request(session_file: &Path) -> Option<TakeoverRequest> {
385 std::fs::read_to_string(takeover_path(session_file))
386 .ok()
387 .and_then(|text| serde_json::from_str(&text).ok())
388}
389
390pub fn clear_takeover_request(session_file: &Path) {
392 let _ = std::fs::remove_file(takeover_path(session_file));
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 fn info(client: &str) -> LeaseInfo {
400 LeaseInfo::for_process(client, std::env::temp_dir().as_path())
401 }
402
403 #[test]
404 fn takeover_request_roundtrip() {
405 let dir = tempfile::tempdir().unwrap();
406 let file = dir.path().join("s.jsonl");
407 assert!(read_takeover_request(&file).is_none());
408 write_takeover_request(&file, &info("oino test")).unwrap();
409 let request = read_takeover_request(&file).unwrap();
410 assert_eq!(request.requester, info("oino test"));
411 assert_eq!(request.v, 1);
412 clear_takeover_request(&file);
413 assert!(read_takeover_request(&file).is_none());
414 }
415
416 #[test]
417 fn lock_paths_are_siblings() {
418 let file = Path::new("/sessions/--x--/1_a.jsonl");
419 assert_eq!(lock_file_path(file).file_name().unwrap(), "1_a.jsonl.lock");
420 assert_eq!(
421 lock_metadata_path(file).file_name().unwrap(),
422 "1_a.jsonl.lock.json"
423 );
424 assert_eq!(
425 takeover_path(file).file_name().unwrap(),
426 "1_a.jsonl.takeover"
427 );
428 }
429
430 #[test]
431 fn dead_flags_same_machine_pid() {
432 assert!(!info("x").dead(), "this process is alive");
433 let mut gone = info("x");
434 gone.pid = 2_000_000; assert!(gone.dead(), "an unmapped pid on the same host+boot is gone");
436 let mut other_host = info("x");
437 other_host.host = "elsewhere".to_owned();
438 assert!(!other_host.dead(), "another host is none of our business");
439 }
440}