Skip to main content

alpha2_os/
lib.rs

1//! The operating-system calls the native host makes that the standard library
2//! does not offer at this workspace's minimum Rust version, behind safe
3//! functions: an advisory lock on a file, a rename that never replaces its
4//! target, a handler for a request to terminate the process, the process's
5//! resident memory, the work the calling thread has done, and a diagnostic
6//! line on standard error that a closed stream does not turn into a panic.
7//!
8//! The rest of the workspace forbids `unsafe`; this crate, like
9//! `alpha2-ffmpeg-bridge`, is where the few calls that need it are kept and
10//! reviewed.
11
12use std::fs::File;
13use std::io;
14use std::path::Path;
15
16#[cfg(unix)]
17mod unix;
18#[cfg(unix)]
19use unix as platform;
20#[cfg(windows)]
21mod windows;
22#[cfg(windows)]
23use windows as platform;
24
25/// Takes an exclusive advisory lock on `file` without waiting: `Ok(true)`
26/// when this handle now holds it, `Ok(false)` when another open handle (in
27/// this process or another) does.
28///
29/// The lock belongs to this handle and goes with it: closing the file, or the
30/// process ending in any way, crash and `SIGKILL` included, releases it. Other
31/// handles of the file can still read and write all of it, so a library that
32/// opens the same path itself (as `FFmpeg` does) is not affected.
33///
34/// It never covers a byte a write can reach where a lock would refuse other
35/// handles' reads and writes of what it covers:
36/// - On Windows it is `LockFileEx` on one byte at offset `i64::MAX`, because a
37///   Windows byte-range lock is mandatory.
38/// - On Linux it is an open-file-description lock (`fcntl(F_OFD_SETLK)`) on
39///   that same byte. A Linux CIFS mount (with the default `brl`) turns a lock
40///   into a mandatory byte-range lock on the server, and `flock` covers the
41///   whole file there, so `FFmpeg`'s writes through its own handle would fail
42///   with `EACCES`. A traditional `fcntl` lock would not do either: it belongs
43///   to the process, and closing any handle of the file (as `FFmpeg` does)
44///   releases it. A kernel or file system that does not take the
45///   open-file-description lock (`EINVAL`) gets `flock`.
46/// - On macOS and other Unix it is `flock`.
47///
48/// The lock is a write lock: on Linux `file` must be open for writing.
49///
50/// # Errors
51/// When the operating system refuses the lock for a reason other than another
52/// holder, for example on a file system without locks.
53pub fn try_lock_exclusive(file: &File) -> io::Result<bool> {
54    platform::try_lock_exclusive(file)
55}
56
57/// Whether `path` names the file `file` is open on now: `false` when the path
58/// no longer exists, names another file (one removed and created again), or
59/// names a removed file that some handle keeps open (`file`'s own included):
60/// Windows and an SMB share keep such a name until the last handle closes.
61///
62/// # Errors
63/// When the file's identity cannot be read.
64pub fn same_file(file: &File, path: &Path) -> io::Result<bool> {
65    platform::same_file(file, path)
66}
67
68/// Renames `from` to `to` unless `to` exists, in one step: a file that
69/// appears at `to` at any moment, up to the rename itself, is never replaced,
70/// and the rename fails with [`io::ErrorKind::AlreadyExists`].
71///
72/// `std::fs::rename` replaces whatever is at `to`. This is
73/// `renameat2(RENAME_NOREPLACE)` on Linux, `renamex_np(RENAME_EXCL)` on
74/// macOS, and `MoveFileExW` without `MOVEFILE_REPLACE_EXISTING` on Windows. On
75/// Unix, a file system or kernel that refuses the flag (the call answers
76/// `EINVAL`, `ENOSYS` or `ENOTSUP`) gets a hard link to `to`, which fails the
77/// same way when `to` exists, and then the removal of `from`.
78///
79/// # Errors
80/// [`io::ErrorKind::AlreadyExists`] when `to` exists, else what the operating
81/// system reports.
82pub fn rename_no_replace(from: &Path, to: &Path) -> io::Result<()> {
83    platform::rename_no_replace(from, to)
84}
85
86/// A request to end the process: an interrupt or termination signal on Unix,
87/// a console control event on Windows.
88#[derive(Clone, Copy, Debug, Eq, PartialEq)]
89pub struct Termination {
90    name: &'static str,
91    exit_code: u8,
92    closing: bool,
93}
94
95impl Termination {
96    /// A termination as the platform reports it; also how a test describes
97    /// one.
98    #[must_use]
99    pub const fn new(name: &'static str, exit_code: u8, closing: bool) -> Self {
100        Self {
101            name,
102            exit_code,
103            closing,
104        }
105    }
106
107    /// What asked, such as `SIGTERM` or `CTRL_C_EVENT`.
108    #[must_use]
109    pub const fn name(self) -> &'static str {
110        self.name
111    }
112
113    /// The exit status a process that ends because of it reports: `128` plus
114    /// the signal number, as a shell reports a process the signal killed. On
115    /// Windows an interrupt or break reports `130` (as `SIGINT` would) and a
116    /// closing console, logoff or shutdown `143` (as `SIGTERM` would).
117    #[must_use]
118    pub const fn exit_code(self) -> u8 {
119        self.exit_code
120    }
121
122    /// Whether the operating system ends the process as soon as the handler
123    /// returns (a Windows console closing, a logoff, a shutdown), so a handler
124    /// that has cleanup to wait for must wait before it returns.
125    #[must_use]
126    pub const fn closing(self) -> bool {
127        self.closing
128    }
129}
130
131/// Calls `handler` on a thread of its own each time the process is asked to
132/// terminate, instead of the process ending at once.
133///
134/// On Unix it handles `SIGTERM` and `SIGINT`. Each signal is handled once:
135/// the second of a kind ends the process as it would have without this. On
136/// Windows it handles the console control events; a second interrupt or
137/// break ends the process. `TerminateProcess` (what Node's `kill()` does on
138/// Windows) and `SIGKILL` cannot be handled, and still end it at once.
139///
140/// # Errors
141/// When a handler is already installed, or the operating system refuses one.
142pub fn on_termination(handler: impl Fn(Termination) + Send + Sync + 'static) -> io::Result<()> {
143    platform::on_termination(Box::new(handler))
144}
145
146/// The process's resident memory, as the operating system counts it.
147#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
148pub struct ProcessMemory {
149    /// Bytes resident now: `VmRSS` on Linux, the task's resident size on
150    /// macOS, the working set on Windows.
151    pub resident_bytes: Option<u64>,
152    /// The most bytes resident at once so far: `VmHWM` on Linux,
153    /// `ru_maxrss` on macOS, the peak working set on Windows.
154    pub peak_resident_bytes: Option<u64>,
155}
156
157/// The process's resident memory now and at its peak; a figure the
158/// operating system does not report (or this build does not read on it) is
159/// `None`.
160#[must_use]
161pub fn process_memory() -> ProcessMemory {
162    #[cfg(any(unix, windows))]
163    {
164        platform::process_memory()
165    }
166    #[cfg(not(any(unix, windows)))]
167    {
168        ProcessMemory::default()
169    }
170}
171
172/// The work the calling thread has done so far, as a count that only this
173/// thread's other readings are comparable to: CPU nanoseconds on Unix
174/// (`CLOCK_THREAD_CPUTIME_ID`), CPU cycles on Windows
175/// (`QueryThreadCycleTime`). Time the thread spent waiting for a CPU, as on a
176/// busy machine, is not in it, which is what a test that compares the work
177/// of two input sizes needs. `None` where the operating system does not
178/// report it (or this build does not read it there).
179#[must_use]
180pub fn thread_cpu_work() -> Option<u64> {
181    #[cfg(any(unix, windows))]
182    {
183        platform::thread_cpu_work()
184    }
185    #[cfg(not(any(unix, windows)))]
186    {
187        None
188    }
189}
190
191/// Writes one line to standard error, the way `eprintln!` does, and drops
192/// it when the stream is closed or full instead of panicking.
193///
194/// A host's diagnostics are for whoever reads its standard error; a client
195/// that stopped reading (a protocol client that closed the pipe, a timeout
196/// that closed it first) must not end an export, or a worker thread, with a
197/// panic over a line nobody will read. Use it through [`diagnostic!`].
198pub fn write_diagnostic(line: std::fmt::Arguments<'_>) {
199    use std::io::Write;
200    let _ = writeln!(io::stderr().lock(), "{line}");
201}
202
203/// `eprintln!` for the native host and its libraries: the same line on
204/// standard error, dropped when the stream cannot take it
205/// ([`write_diagnostic`]).
206#[macro_export]
207macro_rules! diagnostic {
208    ($($argument:tt)*) => {
209        $crate::write_diagnostic(::std::format_args!($($argument)*))
210    };
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use std::fs::{self, OpenOptions};
217
218    #[test]
219    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
220    fn the_process_reports_its_resident_memory() {
221        let memory = process_memory();
222        let resident = memory.resident_bytes.expect("resident bytes");
223        let peak = memory.peak_resident_bytes.expect("peak resident bytes");
224        // A running test binary holds at least a megabyte, and never more than it ever held.
225        assert!(resident > 1 << 20, "{memory:?}");
226        assert!(peak >= resident, "{memory:?}");
227    }
228
229    #[test]
230    #[cfg(any(unix, windows))]
231    fn the_thread_counts_the_work_it_does_and_not_the_time_it_sleeps() {
232        let started = thread_cpu_work().expect("thread work");
233        std::thread::sleep(std::time::Duration::from_millis(200));
234        let slept = thread_cpu_work().expect("thread work") - started;
235        let mut state = 1_u64;
236        let busy = std::time::Instant::now();
237        while busy.elapsed() < std::time::Duration::from_millis(200) {
238            state = std::hint::black_box(state.wrapping_mul(6_364_136_223_846_793_005));
239        }
240        let worked = thread_cpu_work().expect("thread work") - started - slept;
241        assert!(worked > slept * 4, "slept {slept}, worked {worked}");
242    }
243
244    fn scratch(name: &str) -> std::path::PathBuf {
245        let path = std::env::temp_dir().join(format!("alpha2-os-{}-{name}", std::process::id()));
246        let _ = fs::remove_file(&path);
247        path
248    }
249
250    fn open(path: &Path) -> File {
251        OpenOptions::new()
252            .read(true)
253            .write(true)
254            .create(true)
255            .truncate(false)
256            .open(path)
257            .expect("open")
258    }
259
260    /// Held by the tests that check a closed handle releases its lock, and by the one that
261    /// starts a child process: a child between `fork` and `exec` holds a copy of every open
262    /// descriptor, so a handle another test closes meanwhile keeps its lock until the `exec`
263    /// (a window valgrind makes long enough to hit).
264    static FORK: std::sync::Mutex<()> = std::sync::Mutex::new(());
265
266    #[test]
267    fn one_handle_at_a_time_holds_the_lock_and_closing_it_releases_it() {
268        let _fork = FORK
269            .lock()
270            .unwrap_or_else(std::sync::PoisonError::into_inner);
271        let path = scratch("lock");
272        let first = open(&path);
273        let second = open(&path);
274        assert!(try_lock_exclusive(&first).expect("lock"));
275        assert!(!try_lock_exclusive(&second).expect("second lock"));
276        drop(first);
277        assert!(try_lock_exclusive(&second).expect("lock after release"));
278        let _ = fs::remove_file(&path);
279    }
280
281    #[test]
282    fn another_handle_writes_the_whole_of_a_locked_file() {
283        use std::io::{Read, Write};
284        let path = scratch("write");
285        let lock = open(&path);
286        assert!(try_lock_exclusive(&lock).expect("lock"));
287        let mut writer = File::create(&path).expect("writer truncates and opens");
288        writer
289            .write_all(&[7_u8; 4096])
290            .expect("write under the lock");
291        drop(writer);
292        let mut read = Vec::new();
293        File::open(&path)
294            .expect("reader")
295            .read_to_end(&mut read)
296            .expect("read under the lock");
297        assert_eq!(read, vec![7_u8; 4096]);
298        drop(lock);
299        let _ = fs::remove_file(&path);
300    }
301
302    /// Set in a child of [`the_lock_stays_with_its_handle_when_another_handle_closes`]: the
303    /// file whose lock [`lock_probe_child`] tries to take.
304    const LOCK_PROBE: &str = "ALPHA2_OS_LOCK_PROBE";
305
306    /// Whether another process can take the lock on `path` now, asked of a child process (this
307    /// test binary running [`lock_probe_child`]): another process's attempt is the one every
308    /// platform's lock must refuse.
309    fn another_process_takes_the_lock(path: &Path) -> bool {
310        let status = std::process::Command::new(std::env::current_exe().expect("test binary"))
311            .args(["--exact", "tests::lock_probe_child", "--test-threads=1"])
312            .env(LOCK_PROBE, path)
313            .stdout(std::process::Stdio::null())
314            .status()
315            .expect("run the lock probe");
316        match status.code() {
317            Some(0) => true,
318            Some(3) => false,
319            _ => panic!("the lock probe failed: {status}"),
320        }
321    }
322
323    /// Not a test of its own: in a child that [`another_process_takes_the_lock`] starts, tries
324    /// the lock on the file it names and exits `0` when it got it, `3` when another handle holds
325    /// it.
326    #[test]
327    fn lock_probe_child() {
328        let Some(path) = std::env::var_os(LOCK_PROBE) else {
329            return;
330        };
331        let locked = try_lock_exclusive(&open(Path::new(&path))).expect("probe lock");
332        std::process::exit(if locked { 0 } else { 3 });
333    }
334
335    /// `FFmpeg` opens and closes its own handles on the partial file while the export holds the
336    /// lock; a lock that belonged to the process rather than the handle (a traditional POSIX
337    /// `fcntl` lock) would go when the first of them closed.
338    #[test]
339    fn the_lock_stays_with_its_handle_when_another_handle_closes() {
340        let _fork = FORK
341            .lock()
342            .unwrap_or_else(std::sync::PoisonError::into_inner);
343        let path = scratch("other-handle");
344        let lock = open(&path);
345        assert!(try_lock_exclusive(&lock).expect("lock"));
346        drop(File::create(&path).expect("another handle opens and closes"));
347        drop(open(&path));
348        assert!(
349            !another_process_takes_the_lock(&path),
350            "closing another handle released the lock"
351        );
352        drop(lock);
353        assert!(
354            another_process_takes_the_lock(&path),
355            "closing the handle that holds the lock releases it"
356        );
357        let _ = fs::remove_file(&path);
358    }
359
360    /// On Linux the lock is an open-file-description lock on the byte at `i64::MAX` and nothing
361    /// below it, so a file system that enforces byte-range locks on other handles' writes (CIFS)
362    /// still lets `FFmpeg` write the whole file. What another handle sees through
363    /// `F_OFD_GETLK` is the lock itself.
364    #[test]
365    #[cfg(target_os = "linux")]
366    fn the_lock_covers_only_one_byte_no_write_reaches() {
367        use std::os::fd::AsRawFd;
368        let path = scratch("one-byte");
369        let lock = open(&path);
370        assert!(try_lock_exclusive(&lock).expect("lock"));
371        let other = open(&path);
372        let probe = |start: i64, len: i64| {
373            // SAFETY: an all-zero `flock` is valid; the fields the query reads are set below.
374            let mut query: libc::flock = unsafe { std::mem::zeroed() };
375            query.l_type = libc::c_short::try_from(libc::F_WRLCK).expect("F_WRLCK");
376            query.l_whence = libc::c_short::try_from(libc::SEEK_SET).expect("SEEK_SET");
377            query.l_start = start;
378            query.l_len = len;
379            // SAFETY: `other` is open for the call and `query` outlives it.
380            let asked =
381                unsafe { libc::fcntl(other.as_raw_fd(), libc::F_OFD_GETLK, &raw mut query) };
382            assert_eq!(asked, 0, "F_OFD_GETLK: {}", io::Error::last_os_error());
383            query
384        };
385        let everything_below = probe(0, i64::MAX);
386        assert_eq!(
387            i32::from(everything_below.l_type),
388            libc::F_UNLCK,
389            "no byte a write can reach is locked"
390        );
391        let last = probe(0, 0);
392        assert_eq!(
393            i32::from(last.l_type),
394            libc::F_WRLCK,
395            "the lock is an OFD lock"
396        );
397        // The kernel reports a lock that reaches the last offset as running to the end (`l_len`
398        // 0); with nothing below it locked, that is the one byte.
399        assert_eq!(last.l_start, i64::MAX);
400        drop(lock);
401        let _ = fs::remove_file(&path);
402    }
403
404    #[test]
405    fn a_path_moved_away_and_created_again_is_another_file() {
406        let (path, moved) = (scratch("identity"), scratch("identity-moved"));
407        let file = open(&path);
408        assert!(same_file(&file, &path).expect("same"));
409        fs::rename(&path, &moved).expect("move away");
410        assert!(!same_file(&file, &path).expect("missing"));
411        drop(open(&path));
412        assert!(!same_file(&file, &path).expect("replaced"));
413        assert!(same_file(&file, &moved).expect("moved"));
414        let _ = fs::remove_file(&path);
415        let _ = fs::remove_file(&moved);
416    }
417
418    #[test]
419    fn a_removed_file_still_open_is_not_the_file_its_path_names() {
420        // An SMB share (a Linux CIFS mount) keeps a removed file's name, with no links, until its
421        // last handle closes, and refuses to create the name again meanwhile.
422        let path = scratch("identity-removed");
423        let file = open(&path);
424        fs::remove_file(&path).expect("remove");
425        assert!(!same_file(&file, &path).expect("removed"));
426        drop(file);
427        let again = open(&path);
428        assert!(same_file(&again, &path).expect("created again"));
429        drop(again);
430        let _ = fs::remove_file(&path);
431    }
432
433    #[test]
434    fn a_rename_moves_a_file_to_a_free_name_and_never_replaces_one() {
435        let (from, to) = (scratch("rename-from"), scratch("rename-to"));
436        fs::write(&from, b"export").expect("from");
437        fs::write(&to, b"someone else's").expect("to");
438        let refused = rename_no_replace(&from, &to).expect_err("an existing file is not replaced");
439        assert_eq!(refused.kind(), io::ErrorKind::AlreadyExists);
440        assert_eq!(fs::read(&to).expect("to"), b"someone else's");
441        assert_eq!(fs::read(&from).expect("from"), b"export");
442        fs::remove_file(&to).expect("free the name");
443        rename_no_replace(&from, &to).expect("rename to a free name");
444        assert_eq!(fs::read(&to).expect("to"), b"export");
445        assert!(!from.exists());
446        let _ = fs::remove_file(&to);
447    }
448}