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(©, 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, ©).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(©, &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}