Skip to main content

agent_core/core/
lock_file.rs

1//! Exclusive lock files whose body names the holder.
2//!
3//! The session journal lock and the keeper locks write the holder's identity
4//! into the locked file, so a process that is refused can say who has it. On
5//! Unix the lock is an advisory `flock`, which never blocks a read. Windows
6//! byte-range locks are mandatory: fs4 (like `File::try_lock`) locks the whole
7//! file from offset 0, so the refused process could not read the body and
8//! every refusal lost its holder ("held by another process"). There the lock
9//! covers one byte far past any body instead. That byte lies inside the
10//! whole-file range, so it still excludes, and is excluded by, a binary that
11//! locks the whole file.
12//!
13//! A lock is released by dropping its [`ExclusiveLock`], which unlocks
14//! explicitly before the file closes. Closing alone is not enough: a `flock`
15//! belongs to the open file description, and a child forked by any thread of
16//! this process holds a copy of every descriptor until it execs (`O_CLOEXEC`
17//! applies only at exec). Until then the lock would outlive its owner, and an
18//! immediate re-lock be refused in the name of this very process. Windows
19//! releases a closed handle's locks only "depending on available system
20//! resources", so there an explicit unlock is what makes the release prompt.
21
22use std::fs::File;
23use std::io;
24
25/// An exclusive lock on a file, released when dropped.
26#[derive(Debug)]
27pub struct ExclusiveLock {
28    file: File,
29}
30
31impl ExclusiveLock {
32    /// The locked file, e.g. to write the holder's body.
33    pub fn file(&self) -> &File {
34        &self.file
35    }
36}
37
38impl Drop for ExclusiveLock {
39    fn drop(&mut self) {
40        // Unlock first: another descriptor for the same open file description
41        // (a forked child's, until it execs) would keep the lock past close.
42        let _ = imp::unlock(&self.file);
43    }
44}
45
46/// Take the exclusive lock on `file` without waiting: `Ok(None)` if another
47/// handle holds it.
48pub 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    /// High dword of the locked byte's offset (0xFFFF_FFFF_0000_0000): past
77    /// any body, yet inside a whole-file lock's range, which ends at 2^64 - 1.
78    const LOCK_OFFSET_HIGH: u32 = u32::MAX;
79
80    /// The locked byte's offset, for both the lock and its unlock.
81    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        // SAFETY: `file` is an open handle and `overlapped` outlives the call.
91        // The handle is synchronous and the flags never wait, so the call has
92        // finished with `overlapped` when it returns.
93        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    /// Unlock exactly the byte `try_lock_exclusive` locked (Windows only
114    /// unlocks a range that matches a lock).
115    pub(super) fn unlock(file: &std::fs::File) -> std::io::Result<()> {
116        let mut overlapped = locked_byte();
117        // SAFETY: as in `try_lock_exclusive`; unlocking never waits.
118        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// The whole-file fs4 lock below is released by closing it, which a
128// concurrently spawned test process can delay until it executes (see
129// `ParkedChild`).
130#[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    /// Windows releases a closed handle's locks "depending on available
144    /// system resources", so a re-lock after a close may need a moment.
145    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    /// A binary that locks the whole file (fs4, before this helper) and one
175    /// that uses it exclude each other both ways.
176    #[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/// A child forked from this process and parked between fork and exec. It
193/// holds a copy of every descriptor open at the fork (`O_CLOEXEC` closes them
194/// only at exec) until released, which is what any thread spawning a process
195/// does for a moment. Use it from `#[serial(process_spawn)]` tests: while
196/// parked it also holds other tests' descriptors.
197#[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    /// Fork a child and return once it is parked before exec.
206    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        // Close-on-exec, like every descriptor std opens.
212        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                // SAFETY: only async-signal-safe calls (write, read, _exit)
220                // between fork and exec.
221                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                                // EOF on the parent's side fails the test.
229                                _ => 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        // Bounded: a child that never signals fails the test, and `Drop`
243        // releases and reaps it.
244        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            // SAFETY: `ready` is one valid pollfd that outlives the call.
250            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        // EOF instead of the byte: the spawn failed before the child parked.
257        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    /// Let the child exec and exit; returns once it is reaped.
265    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        // The byte, or failing that EOF, ends the child's wait.
272        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    /// Dropping the lock releases it while a forked child still holds a
293    /// copy of its descriptor: the next locker, in this process or another,
294    /// gets it at once instead of being refused in this process's name.
295    #[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        // The child is parked before exec, holding an inherited copy.
305        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}