1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
//! 1.3.36 hardening — an advisory single-instance lock on a project.
//!
//! Two `inkhaven` TUI sessions open on the same project both write
//! `metadata.db` and `.session.json`; interleaved writes can corrupt
//! the store. This guards against that **without ever hard-blocking**
//! — in keeping with Inkhaven's permissive principle, the lock
//! *informs*. The interactive launcher warns and lets the author open
//! anyway; only genuine data-safety is at stake, and the choice stays
//! theirs.
//!
//! Mechanism: an OS advisory lock (`flock`/`LockFileEx` via `fs2`) on
//! `<project>/.inkhaven.lock`. The kernel releases it automatically
//! when the holding process exits — including on a crash or `kill -9`
//! — so there is **no stale-lock cleanup** to get wrong: a dead
//! session never locks anyone out. The PID/host/time written into the
//! file are only there to make the "already open" warning friendly.
use std::fs::{File, OpenOptions};
use std::io::{self, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use fs2::FileExt;
/// Identifying details of whoever currently holds the lock — read
/// back from the lockfile purely to make the warning informative.
#[derive(Debug, Clone, Default)]
pub struct LockInfo {
pub pid: u32,
pub host: String,
/// Unix seconds when the holder acquired the lock.
pub started_at: i64,
}
impl LockInfo {
fn current() -> Self {
let host = std::env::var("HOSTNAME")
.or_else(|_| std::env::var("COMPUTERNAME"))
.unwrap_or_default();
// `now` is read here (not via a forbidden Date::now in scripts)
// — this is ordinary runtime code.
let started_at = chrono::Utc::now().timestamp();
Self { pid: std::process::id(), host, started_at }
}
fn to_line(&self) -> String {
format!("{} {} {}\n", self.pid, self.started_at, self.host)
}
fn parse(s: &str) -> Self {
let line = s.lines().next().unwrap_or("");
let mut parts = line.splitn(3, ' ');
let pid = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let started_at = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let host = parts.next().unwrap_or("").trim().to_string();
Self { pid, host, started_at }
}
/// Human phrasing for the warning, e.g. `PID 4321 on mac since 14:02`.
pub fn describe(&self) -> String {
let when = chrono::DateTime::from_timestamp(self.started_at, 0)
.map(|dt| dt.with_timezone(&chrono::Local).format("%H:%M").to_string())
.unwrap_or_else(|| "an earlier time".into());
let host = if self.host.is_empty() {
String::new()
} else {
format!(" on {}", self.host)
};
format!("PID {}{host} since {when}", self.pid)
}
}
/// A held project lock. Dropping it releases the OS lock (the kernel
/// also releases on process exit, so a crash can't leave it stuck).
pub struct ProjectLock {
file: File,
#[allow(dead_code)]
path: PathBuf,
}
impl Drop for ProjectLock {
fn drop(&mut self) {
// Best-effort: the fd close on `file`'s drop would release the
// lock anyway; doing it explicitly keeps intent clear. The
// lockfile itself is left in place as a stable anchor — empty
// and harmless, reused on the next launch — which avoids the
// unlink-vs-reopen race a removal would introduce.
let _ = self.file.unlock();
}
}
/// The result of trying to lock a project.
pub enum LockOutcome {
/// We hold the lock (or locking isn't supported on this
/// filesystem and we proceeded permissively). Keep the value
/// alive for the session.
Acquired(ProjectLock),
/// Another live instance holds it; `LockInfo` describes it for the
/// warning. We did NOT acquire — the caller decides whether to
/// proceed anyway.
Busy(LockInfo),
}
/// Try to acquire the advisory lock for `project_root`.
///
/// Never blocks. Returns `Acquired` when we take the lock; `Busy` when
/// a live instance holds it. A filesystem that doesn't support
/// advisory locks (rare — some network mounts) degrades to `Acquired`
/// without a real lock rather than locking the author out.
pub fn acquire(project_root: &Path) -> io::Result<LockOutcome> {
let path = project_root.join(".inkhaven.lock");
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(&path)?;
match file.try_lock_exclusive() {
Ok(()) => {
// We hold it — stamp our identity for anyone who comes next.
write_info(&file, &LockInfo::current());
Ok(LockOutcome::Acquired(ProjectLock { file, path }))
}
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
let info = std::fs::read_to_string(&path)
.map(|s| LockInfo::parse(&s))
.unwrap_or_default();
Ok(LockOutcome::Busy(info))
}
Err(_) => {
// Locking unsupported here. Permissive: proceed unlocked
// rather than refuse to open the project.
Ok(LockOutcome::Acquired(ProjectLock { file, path }))
}
}
}
/// Truncate + rewrite the lockfile with the current holder's identity.
/// Best-effort: a failure here only degrades the warning message, not
/// the lock itself.
fn write_info(mut file: &File, info: &LockInfo) {
let _ = file.set_len(0);
let _ = file.seek(SeekFrom::Start(0));
let _ = file.write_all(info.to_line().as_bytes());
let _ = file.flush();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn acquire_then_contend_then_release() {
let dir = tempfile::tempdir().unwrap();
let first = acquire(dir.path()).unwrap();
assert!(matches!(first, LockOutcome::Acquired(_)));
// A second attempt while the first is held must report Busy
// with the holder's PID — never block, never panic.
match acquire(dir.path()).unwrap() {
LockOutcome::Busy(info) => {
assert_eq!(info.pid, std::process::id());
}
LockOutcome::Acquired(_) => panic!("expected Busy while first lock is held"),
}
// Releasing the first lets the next attempt succeed.
drop(first);
assert!(matches!(acquire(dir.path()).unwrap(), LockOutcome::Acquired(_)));
}
#[test]
fn lock_info_round_trips() {
let info = LockInfo { pid: 4321, host: "mac".into(), started_at: 1_700_000_000 };
let parsed = LockInfo::parse(&info.to_line());
assert_eq!(parsed.pid, 4321);
assert_eq!(parsed.host, "mac");
assert_eq!(parsed.started_at, 1_700_000_000);
assert!(parsed.describe().contains("PID 4321"));
assert!(parsed.describe().contains("on mac"));
}
#[test]
fn missing_host_describes_cleanly() {
let info = LockInfo { pid: 7, host: String::new(), started_at: 1_700_000_000 };
let d = info.describe();
assert!(d.contains("PID 7"));
assert!(!d.contains(" on "));
}
}