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
use std::fs::{self, File, OpenOptions};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
/// Advisory file lock for serializing writes to the Frame project.
///
/// Uses platform-native flock (Unix) to coordinate between the TUI
/// and CLI processes.
pub struct FileLock {
_file: File,
path: PathBuf,
/// Whether releasing the lock also unlinks the lock file. Safe for the
/// project lock, whose file is only ever locked in place; **not** safe for a
/// lock guarding a file that gets replaced by `rename(2)` — see
/// [`FileLock::acquire_at`].
remove_on_drop: bool,
}
/// Error type for lock operations
#[derive(Debug, thiserror::Error)]
pub enum LockError {
#[error("could not create lock file at {path}: {source}")]
CreateError {
path: PathBuf,
source: std::io::Error,
},
#[error("could not acquire lock on {path}: another frame process may be writing")]
Timeout { path: PathBuf },
#[error("lock error: {0}")]
IoError(#[from] std::io::Error),
}
impl FileLock {
/// Acquire an advisory lock on the frame directory.
/// Blocks up to `timeout` waiting for the lock.
pub fn acquire(frame_dir: &Path, timeout: Duration) -> Result<Self, LockError> {
Self::lock_file(frame_dir.join(".lock"), timeout, true)
}
/// Acquire an advisory lock on an arbitrary path, leaving the lock file in
/// place when the lock is released.
///
/// A lock guarding a file that is replaced by `rename(2)` **must** use a
/// dedicated lock file locked this way. Unlinking it would let a waiter
/// inherit the lock on an unlinked inode while a newcomer creates a fresh
/// file and locks that — two writers, one "lock".
pub fn acquire_at(lock_path: &Path, timeout: Duration) -> Result<Self, LockError> {
Self::lock_file(lock_path.to_path_buf(), timeout, false)
}
fn lock_file(
lock_path: PathBuf,
timeout: Duration,
remove_on_drop: bool,
) -> Result<Self, LockError> {
let file = OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.map_err(|e| LockError::CreateError {
path: lock_path.clone(),
source: e,
})?;
let start = Instant::now();
loop {
match try_lock(&file) {
Ok(()) => {
return Ok(FileLock {
_file: file,
path: lock_path,
remove_on_drop,
});
}
Err(_) if start.elapsed() < timeout => {
std::thread::sleep(Duration::from_millis(10));
}
Err(_) => {
return Err(LockError::Timeout { path: lock_path });
}
}
}
}
/// Acquire with default timeout (5 seconds)
pub fn acquire_default(frame_dir: &Path) -> Result<Self, LockError> {
Self::acquire(frame_dir, Duration::from_secs(5))
}
}
impl Drop for FileLock {
fn drop(&mut self) {
// Lock is released automatically when the file is dropped (flock semantics)
// Optionally clean up the lock file
if self.remove_on_drop {
let _ = fs::remove_file(&self.path);
}
}
}
/// Try to acquire an exclusive flock on the file (non-blocking)
#[cfg(unix)]
fn try_lock(file: &File) -> Result<(), std::io::Error> {
use std::os::unix::io::AsRawFd;
let fd = file.as_raw_fd();
let result = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if result == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
#[cfg(not(unix))]
fn try_lock(_file: &File) -> Result<(), std::io::Error> {
// On non-Unix platforms, just succeed (advisory locking)
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_acquire_and_release_lock() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
fs::create_dir_all(&frame_dir).unwrap();
let lock = FileLock::acquire_default(&frame_dir);
assert!(lock.is_ok());
// Lock should be released when dropped
drop(lock);
// Should be able to acquire again
let lock2 = FileLock::acquire_default(&frame_dir);
assert!(lock2.is_ok());
}
#[test]
fn test_lock_contention() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
fs::create_dir_all(&frame_dir).unwrap();
// Acquire first lock
let _lock1 = FileLock::acquire_default(&frame_dir).unwrap();
// Second lock should timeout quickly
let lock2 = FileLock::acquire(&frame_dir, Duration::from_millis(50));
assert!(lock2.is_err());
}
}