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}