agent_core/core/
lock_file.rs1use std::fs::File;
23use std::io;
24
25#[derive(Debug)]
27pub struct ExclusiveLock {
28 file: File,
29}
30
31impl ExclusiveLock {
32 pub fn file(&self) -> &File {
34 &self.file
35 }
36}
37
38impl Drop for ExclusiveLock {
39 fn drop(&mut self) {
40 let _ = imp::unlock(&self.file);
43 }
44}
45
46pub fn try_lock(file: File) -> io::Result<Option<ExclusiveLock>> {
49 Ok(try_lock_exclusive(&file)?.then_some(ExclusiveLock { file }))
50}
51
52fn try_lock_exclusive(file: &File) -> io::Result<bool> {
53 imp::try_lock_exclusive(file)
54}
55
56#[cfg(not(windows))]
57mod imp {
58 pub(super) fn try_lock_exclusive(file: &std::fs::File) -> std::io::Result<bool> {
59 fs4::fs_std::FileExt::try_lock_exclusive(file)
60 }
61
62 pub(super) fn unlock(file: &std::fs::File) -> std::io::Result<()> {
63 fs4::fs_std::FileExt::unlock(file)
64 }
65}
66
67#[cfg(windows)]
68mod imp {
69 use std::os::windows::io::AsRawHandle;
70 use windows_sys::Win32::Foundation::{ERROR_IO_PENDING, ERROR_LOCK_VIOLATION};
71 use windows_sys::Win32::Storage::FileSystem::{
72 LockFileEx, UnlockFileEx, LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY,
73 };
74 use windows_sys::Win32::System::IO::{OVERLAPPED, OVERLAPPED_0, OVERLAPPED_0_0};
75
76 const LOCK_OFFSET_HIGH: u32 = u32::MAX;
79
80 fn locked_byte() -> OVERLAPPED {
82 OVERLAPPED {
83 Anonymous: OVERLAPPED_0 { Anonymous: OVERLAPPED_0_0 { Offset: 0, OffsetHigh: LOCK_OFFSET_HIGH } },
84 ..Default::default()
85 }
86 }
87
88 pub(super) fn try_lock_exclusive(file: &std::fs::File) -> std::io::Result<bool> {
89 let mut overlapped = locked_byte();
90 let ok = unsafe {
94 LockFileEx(
95 file.as_raw_handle(),
96 LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
97 0,
98 1,
99 0,
100 &mut overlapped,
101 )
102 };
103 if ok != 0 {
104 return Ok(true);
105 }
106 let err = std::io::Error::last_os_error();
107 match err.raw_os_error() {
108 Some(code) if code == ERROR_LOCK_VIOLATION as i32 || code == ERROR_IO_PENDING as i32 => Ok(false),
109 _ => Err(err),
110 }
111 }
112
113 pub(super) fn unlock(file: &std::fs::File) -> std::io::Result<()> {
116 let mut overlapped = locked_byte();
117 let ok = unsafe { UnlockFileEx(file.as_raw_handle(), 0, 1, 0, &mut overlapped) };
119 if ok != 0 {
120 Ok(())
121 } else {
122 Err(std::io::Error::last_os_error())
123 }
124 }
125}
126
127#[cfg(test)]
131#[serial_test::parallel(process_spawn)]
132mod tests {
133 use super::*;
134 use std::fs::OpenOptions;
135 use std::io::Write;
136 use std::path::Path;
137 use std::time::{Duration, Instant};
138
139 fn open(path: &Path) -> File {
140 OpenOptions::new().create(true).truncate(false).read(true).write(true).open(path).unwrap()
141 }
142
143 fn relock_within(file: &File, lock: impl Fn(&File) -> io::Result<bool>) -> bool {
146 let t0 = Instant::now();
147 loop {
148 if lock(file).unwrap() {
149 return true;
150 }
151 if t0.elapsed() > Duration::from_secs(5) {
152 return false;
153 }
154 std::thread::sleep(Duration::from_millis(20));
155 }
156 }
157
158 #[test]
159 fn a_refused_locker_can_read_the_holders_body() {
160 let dir = tempfile::tempdir().unwrap();
161 let path = dir.path().join("x.lock");
162 let held = try_lock(open(&path)).unwrap().expect("uncontended");
163 held.file().write_all(b"4242\ntui\n").unwrap();
164
165 let other = open(&path);
166 assert!(!try_lock_exclusive(&other).unwrap(), "held");
167 assert!(try_lock(open(&path)).unwrap().is_none(), "held");
168 assert_eq!(std::fs::read_to_string(&path).unwrap(), "4242\ntui\n");
169
170 drop(held);
171 assert!(relock_within(&other, try_lock_exclusive), "released on drop");
172 }
173
174 #[test]
177 fn excludes_and_is_excluded_by_a_whole_file_lock() {
178 let dir = tempfile::tempdir().unwrap();
179 let path = dir.path().join("x.lock");
180 let whole = open(&path);
181 assert!(fs4::fs_std::FileExt::try_lock_exclusive(&whole).unwrap());
182 let ours = open(&path);
183 assert!(!try_lock_exclusive(&ours).unwrap(), "a whole-file lock excludes ours");
184
185 drop(whole);
186 assert!(relock_within(&ours, try_lock_exclusive));
187 let late = open(&path);
188 assert!(!fs4::fs_std::FileExt::try_lock_exclusive(&late).unwrap(), "ours excludes a whole-file lock");
189 }
190}
191
192#[cfg(all(test, unix))]
198pub(crate) struct ParkedChild {
199 release: Option<io::PipeWriter>,
200 spawner: Option<std::thread::JoinHandle<io::Result<std::process::ExitStatus>>>,
201}
202
203#[cfg(all(test, unix))]
204impl ParkedChild {
205 pub(crate) fn park() -> Self {
207 use std::io::Read;
208 use std::os::fd::AsRawFd;
209 use std::os::unix::process::CommandExt;
210 use std::time::{Duration, Instant};
211 let (mut forked, signal) = io::pipe().unwrap();
213 let (hold, release) = io::pipe().unwrap();
214 let (signal_fd, hold_fd) = (signal.as_raw_fd(), hold.as_raw_fd());
215 let mut child = Self {
216 release: Some(release),
217 spawner: Some(std::thread::spawn(move || {
218 let mut command = std::process::Command::new("true");
219 unsafe {
222 command.pre_exec(move || {
223 let interrupted = || io::Error::last_os_error().raw_os_error() == Some(libc::EINTR);
224 loop {
225 match libc::write(signal_fd, b"f".as_ptr().cast(), 1) {
226 1 => break,
227 -1 if interrupted() => {}
228 _ => libc::_exit(1),
230 }
231 }
232 let mut byte = 0u8;
233 while libc::read(hold_fd, (&mut byte as *mut u8).cast(), 1) < 0 && interrupted() {}
234 Ok(())
235 });
236 }
237 let status = command.status();
238 drop((signal, hold));
239 status
240 })),
241 };
242 let deadline = Instant::now() + Duration::from_secs(30);
245 loop {
246 let mut ready = libc::pollfd { fd: forked.as_raw_fd(), events: libc::POLLIN, revents: 0 };
247 let left = deadline.saturating_duration_since(Instant::now());
248 let timeout = left.as_millis().try_into().unwrap_or(i32::MAX);
249 match unsafe { libc::poll(&mut ready, 1, timeout) } {
251 1 => break,
252 0 => panic!("the forked child never parked"),
253 _ => assert_eq!(io::Error::last_os_error().raw_os_error(), Some(libc::EINTR)),
254 }
255 }
256 if let Err(e) = forked.read_exact(&mut [0; 1]) {
258 let spawn = child.release_and_reap();
259 panic!("the forked child never parked: {e}; spawn: {spawn:?}");
260 }
261 child
262 }
263
264 pub(crate) fn release(mut self) {
266 let status = self.release_and_reap().expect("not yet released");
267 assert!(status.expect("spawner panicked").expect("spawn failed").success());
268 }
269
270 fn release_and_reap(&mut self) -> Option<std::thread::Result<io::Result<std::process::ExitStatus>>> {
271 if let Some(mut release) = self.release.take() {
273 use std::io::Write;
274 let _ = release.write_all(b"r");
275 }
276 self.spawner.take().map(std::thread::JoinHandle::join)
277 }
278}
279
280#[cfg(all(test, unix))]
281impl Drop for ParkedChild {
282 fn drop(&mut self) {
283 let _ = self.release_and_reap();
284 }
285}
286
287#[cfg(all(test, unix))]
288mod fork_tests {
289 use super::*;
290 use std::fs::OpenOptions;
291
292 #[test]
296 #[serial_test::serial(process_spawn)]
297 fn a_descriptor_inherited_by_a_forked_child_does_not_outlive_the_lock() {
298 let dir = tempfile::tempdir().unwrap();
299 let path = dir.path().join("x.lock");
300 let open = || OpenOptions::new().create(true).truncate(false).read(true).write(true).open(&path).unwrap();
301 let held = try_lock(open()).unwrap().expect("uncontended");
302 let child = ParkedChild::park();
303 drop(held);
304 let relocked = try_lock(open()).unwrap();
306 child.release();
307 assert!(relocked.is_some(), "a forked child's inherited descriptor kept the lock");
308 }
309}