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