Skip to main content

alpha2_os/
lib.rs

1//! Safe wrappers for the operating-system calls the native host needs that the standard library
2//! does not offer at the minimum Rust version: file locks, no-replace publishing, termination
3//! handling, parent and child lifetime, clocks, memory, and the OS version.
4//!
5//! The rest of the workspace forbids `unsafe`; this crate and `alpha2-ffmpeg-bridge` hold the few
6//! calls that need it.
7
8use std::fs::File;
9use std::io;
10use std::path::Path;
11use std::time::{Duration, Instant};
12
13mod lifetime;
14mod memory;
15mod os_version;
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
25pub use lifetime::{
26    KillOnCloseJob, exit_without_handlers, stop_std_handle_inheritance, watch_parent,
27};
28pub use memory::{MemoryCapacity, memory_capacity};
29#[cfg(any(target_os = "linux", target_os = "android"))]
30pub use memory::{cgroup_v2_usage, memory_capacity_at};
31pub use os_version::os_version;
32
33/// Takes an exclusive advisory lock on `file` without waiting. Returns `Ok(false)` when another
34/// open handle, in this or another process, holds it.
35///
36/// The lock belongs to this handle and is released when it closes or the process dies. Other
37/// handles can still read and write the whole file, so `FFmpeg` opening the same path is not
38/// affected. Where locks are mandatory, the lock covers only a byte no write can reach:
39/// - Windows: `LockFileEx` on one byte at offset `i64::MAX`.
40/// - Linux: an open-file-description lock (`F_OFD_SETLK`) on that byte. On CIFS mounts `flock`
41///   becomes a mandatory whole-file lock that would fail `FFmpeg`'s writes, and a classic `fcntl`
42///   lock is dropped when any handle of the file closes. Falls back to `flock` on `EINVAL`.
43/// - macOS and other Unix: `flock`.
44///
45/// On Linux `file` must be open for writing.
46///
47/// # Errors
48/// When the OS refuses the lock for a reason other than another holder, for example on a file
49/// system without locks.
50pub fn try_lock_exclusive(file: &File) -> io::Result<bool> {
51    platform::try_lock_exclusive(file)
52}
53
54/// Whether `path` still names the file `file` is open on. `false` when the path is gone, names a
55/// recreated file, or names a removed file kept visible by an open handle (Windows and SMB keep
56/// such names until the last handle closes).
57///
58/// # Errors
59/// When the file's identity cannot be read.
60pub fn same_file(file: &File, path: &Path) -> io::Result<bool> {
61    platform::same_file(file, path)
62}
63
64/// Whether `a` and `b` name one file (same device and inode, or volume serial and file index on
65/// Windows). Hard links and symlinks count as the same file. `false` when either path is missing.
66///
67/// # Errors
68/// When either path's identity cannot be read for a reason other than it being missing.
69pub fn same_file_at(a: &Path, b: &Path) -> io::Result<bool> {
70    platform::same_file_at(a, b)
71}
72
73/// How [`publish_no_replace`] or [`publish_replace`] moved a file into place.
74#[derive(Clone, Copy, Debug, Eq, PartialEq)]
75pub enum PublishMethod {
76    /// One rename that refuses an existing target: `renameat2(RENAME_NOREPLACE)`
77    /// on Linux, `renamex_np(RENAME_EXCL)` on macOS, `MoveFileExW` without
78    /// `MOVEFILE_REPLACE_EXISTING` on Windows.
79    RenameNoReplace,
80    /// The file system refused the no-replace flag, so an empty `O_EXCL` placeholder was created
81    /// at the target and renamed over.
82    PlaceholderRename,
83    /// A rename that replaces whatever is at the target.
84    RenameReplace,
85}
86
87impl PublishMethod {
88    /// The name used in reports.
89    #[must_use]
90    pub const fn as_str(self) -> &'static str {
91        match self {
92            Self::RenameNoReplace => "rename-no-replace",
93            Self::PlaceholderRename => "placeholder-rename",
94            Self::RenameReplace => "rename-replace",
95        }
96    }
97}
98
99/// A finished file moved into place.
100#[derive(Clone, Copy, Debug, Eq, PartialEq)]
101pub struct Published {
102    /// How it was moved.
103    pub method: PublishMethod,
104    /// Retries after errors accepted by [`is_retryable_publish_error`].
105    pub retries: u32,
106}
107
108/// How long a publish retries a step refused by another program's open handle.
109#[derive(Clone, Copy)]
110pub struct RetryPolicy<'a> {
111    /// Time from the first try after which no retry starts. Zero tries once.
112    pub budget: Duration,
113    /// Called before each wait; `false` stops and returns the last error.
114    pub on_wait: &'a dyn Fn() -> bool,
115}
116
117fn keep_waiting() -> bool {
118    true
119}
120
121impl RetryPolicy<'static> {
122    /// One try, no retries.
123    pub const NONE: Self = Self {
124        budget: Duration::ZERO,
125        on_wait: &keep_waiting,
126    };
127}
128
129impl std::fmt::Debug for RetryPolicy<'_> {
130    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        formatter
132            .debug_struct("RetryPolicy")
133            .field("budget", &self.budget)
134            .finish_non_exhaustive()
135    }
136}
137
138// Each later wait doubles, up to `RETRY_MAX_DELAY`.
139const RETRY_FIRST_DELAY: Duration = Duration::from_millis(50);
140const RETRY_MAX_DELAY: Duration = Duration::from_secs(1);
141
142/// Runs `step` until it succeeds, fails with a non-retryable error, or `retry` stops it. Waits
143/// 50 ms before the first retry, doubling up to 1 s. Returns the value and the retry count.
144///
145/// # Errors
146/// The step's last error.
147pub fn with_publish_retries<T>(
148    retry: &RetryPolicy<'_>,
149    step: impl FnMut() -> io::Result<T>,
150) -> io::Result<(T, u32)> {
151    retrying(retry, is_retryable_publish_error, step)
152}
153
154fn retrying<T>(
155    retry: &RetryPolicy<'_>,
156    retryable: impl Fn(&io::Error) -> bool,
157    mut step: impl FnMut() -> io::Result<T>,
158) -> io::Result<(T, u32)> {
159    let started = Instant::now();
160    let mut delay = RETRY_FIRST_DELAY;
161    let mut retries = 0_u32;
162    loop {
163        match step() {
164            Ok(value) => return Ok((value, retries)),
165            Err(error) => {
166                let elapsed = started.elapsed();
167                if !retryable(&error) || elapsed >= retry.budget || !(retry.on_wait)() {
168                    return Err(error);
169                }
170                std::thread::sleep(delay.min(retry.budget.saturating_sub(elapsed)));
171                retries = retries.saturating_add(1);
172                delay = (delay * 2).min(RETRY_MAX_DELAY);
173            }
174        }
175    }
176}
177
178/// Whether a publish step failed because another program (antivirus, indexer, media player)
179/// briefly holds the file open: `ERROR_ACCESS_DENIED`, `ERROR_SHARING_VIOLATION` or
180/// `ERROR_LOCK_VIOLATION` on Windows. Always `false` on Unix, where open handles do not block a
181/// rename.
182///
183/// A real permission error also reports `ERROR_ACCESS_DENIED`; retrying it costs at most the
184/// budget.
185#[must_use]
186pub fn is_retryable_publish_error(error: &io::Error) -> bool {
187    platform::is_retryable_publish_error(error)
188}
189
190/// Moves the finished file `from` to `to`, never replacing an existing file at `to`.
191///
192/// Uses the platform's no-replace rename, with verbatim (`\\?\`) paths on Windows to allow
193/// paths over `MAX_PATH`. File systems that refuse the flag (exFAT on macOS, FUSE servers such as
194/// rclone) get [`PublishMethod::PlaceholderRename`] instead. That path has two races the one-step
195/// rename lacks: a process that replaces the empty placeholder before the rename loses its file,
196/// and a crash between the two steps leaves an empty file at `to`.
197///
198/// Retryable errors are retried as `retry` allows.
199///
200/// # Errors
201/// [`io::ErrorKind::AlreadyExists`] when `to` exists, else what the operating
202/// system reports for the last try.
203pub fn publish_no_replace(
204    from: &Path,
205    to: &Path,
206    retry: &RetryPolicy<'_>,
207) -> io::Result<Published> {
208    publish_no_replace_with(from, to, retry, &platform::rename_exclusive)
209}
210
211/// [`publish_no_replace`] with an injectable rename, so tests can refuse the flag.
212fn publish_no_replace_with(
213    from: &Path,
214    to: &Path,
215    retry: &RetryPolicy<'_>,
216    rename_exclusive: &dyn Fn(&Path, &Path) -> io::Result<()>,
217) -> io::Result<Published> {
218    let (method, retries) = with_publish_retries(retry, || match rename_exclusive(from, to) {
219        Ok(()) => Ok(PublishMethod::RenameNoReplace),
220        Err(error) if platform::flag_refused(&error) => {
221            publish_through_placeholder(from, to).map(|()| PublishMethod::PlaceholderRename)
222        }
223        Err(error) => Err(error),
224    })?;
225    Ok(Published { method, retries })
226}
227
228/// Creates an empty `O_EXCL` placeholder at `to`, then renames `from` over it. A failed rename
229/// removes the placeholder if `to` still names it.
230///
231/// The placeholder must close before the rename: a FUSE server that writes back on close (rclone
232/// without a cache) would otherwise write the empty file over the published one.
233fn publish_through_placeholder(from: &Path, to: &Path) -> io::Result<()> {
234    let placeholder = std::fs::OpenOptions::new()
235        .write(true)
236        .create_new(true)
237        .open(to)?;
238    let identity = platform::file_identity(&placeholder);
239    drop(placeholder);
240    let renamed = std::fs::rename(from, to);
241    if renamed.is_err()
242        && let Ok(identity) = identity
243        && platform::path_identity(to).ok().flatten() == Some(identity)
244    {
245        let _ = std::fs::remove_file(to);
246    }
247    renamed
248}
249
250/// Moves the finished file `from` to `to`, replacing any file there. Retryable errors are
251/// retried as `retry` allows.
252///
253/// # Errors
254/// What the operating system reports for the last try.
255pub fn publish_replace(from: &Path, to: &Path, retry: &RetryPolicy<'_>) -> io::Result<Published> {
256    let ((), retries) = with_publish_retries(retry, || std::fs::rename(from, to))?;
257    Ok(Published {
258        method: PublishMethod::RenameReplace,
259        retries,
260    })
261}
262
263/// Makes renames into `dir` survive a power loss by `fsync`ing it on Unix. File systems that
264/// cannot sync a directory are not an error. No-op on Windows, where the rename writes through.
265///
266/// # Errors
267/// When the directory cannot be opened or the sync fails.
268pub fn sync_directory(dir: &Path) -> io::Result<()> {
269    #[cfg(unix)]
270    {
271        platform::sync_directory(dir)
272    }
273    #[cfg(not(unix))]
274    {
275        let _ = dir;
276        Ok(())
277    }
278}
279
280/// What a [`NameLimit`] counts.
281#[derive(Clone, Copy, Debug, Eq, PartialEq)]
282pub enum NameUnit {
283    /// Bytes of the name's UTF-8 encoding (Unix).
284    Bytes,
285    /// UTF-16 code units (Windows).
286    Utf16,
287}
288
289/// The longest file name a directory takes.
290#[derive(Clone, Copy, Debug, Eq, PartialEq)]
291pub struct NameLimit {
292    /// Maximum name length in `unit`.
293    pub max: usize,
294    /// What `max` counts.
295    pub unit: NameUnit,
296}
297
298impl NameLimit {
299    /// The length of `name` in this limit's unit.
300    #[must_use]
301    pub fn length(self, name: &str) -> usize {
302        match self.unit {
303            NameUnit::Bytes => name.len(),
304            NameUnit::Utf16 => name.encode_utf16().count(),
305        }
306    }
307}
308
309/// The longest file name `dir` accepts: `pathconf(_PC_NAME_MAX)` on Unix (255 bytes if
310/// unreported), 255 UTF-16 units on Windows.
311#[must_use]
312pub fn name_limit(dir: &Path) -> NameLimit {
313    platform::name_limit(dir)
314}
315
316/// A request to end the process: an interrupt or termination signal on Unix,
317/// a console control event on Windows.
318#[derive(Clone, Copy, Debug, Eq, PartialEq)]
319pub struct Termination {
320    name: &'static str,
321    exit_code: u8,
322    closing: bool,
323}
324
325impl Termination {
326    /// Creates a termination; platform handlers and tests use this.
327    #[must_use]
328    pub const fn new(name: &'static str, exit_code: u8, closing: bool) -> Self {
329        Self {
330            name,
331            exit_code,
332            closing,
333        }
334    }
335
336    /// What asked, such as `SIGTERM` or `CTRL_C_EVENT`.
337    #[must_use]
338    pub const fn name(self) -> &'static str {
339        self.name
340    }
341
342    /// The exit status to report: `128` plus the signal number, as a shell does. On Windows an
343    /// interrupt or break maps to `130` and a close, logoff or shutdown to `143`.
344    #[must_use]
345    pub const fn exit_code(self) -> u8 {
346        self.exit_code
347    }
348
349    /// Whether the OS kills the process once the handler returns (Windows console close, logoff,
350    /// shutdown), so cleanup must finish before returning.
351    #[must_use]
352    pub const fn closing(self) -> bool {
353        self.closing
354    }
355}
356
357/// Calls `handler` on its own thread when the process is asked to terminate, instead of ending
358/// at once. [`watch_parent`] calls the same handler.
359///
360/// On Unix it handles `SIGTERM`, `SIGINT` and `SIGHUP`; a second signal of a kind ends the process
361/// normally. On Windows it handles console control events; a second interrupt or break calls
362/// [`exit_without_handlers`]. Close, logoff and shutdown events only reach processes that have not
363/// loaded `user32.dll`. `TerminateProcess` and `SIGKILL` cannot be handled.
364///
365/// # Errors
366/// When a handler is already installed, or the operating system refuses one.
367pub fn on_termination(handler: impl Fn(Termination) + Send + Sync + 'static) -> io::Result<()> {
368    platform::on_termination(Box::new(handler))
369}
370
371/// A point on a clock that stops while the system sleeps, so stall checks ignore suspend.
372///
373/// `std::time::Instant` already stops during sleep on Linux and macOS; on Windows this reads
374/// `QueryUnbiasedInterruptTimePrecise` (100 ns units).
375#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
376pub struct AwakeInstant(#[cfg(windows)] u64, #[cfg(not(windows))] Instant);
377
378impl AwakeInstant {
379    /// The current instant.
380    #[must_use]
381    pub fn now() -> Self {
382        #[cfg(windows)]
383        {
384            Self(platform::unbiased_interrupt_time())
385        }
386        #[cfg(not(windows))]
387        {
388            Self(Instant::now())
389        }
390    }
391
392    /// Awake time since `earlier`, or zero when `earlier` is later.
393    #[must_use]
394    pub fn duration_since(self, earlier: Self) -> Duration {
395        #[cfg(windows)]
396        {
397            Duration::from_nanos(self.0.saturating_sub(earlier.0).saturating_mul(100))
398        }
399        #[cfg(not(windows))]
400        {
401            self.0.saturating_duration_since(earlier.0)
402        }
403    }
404
405    /// The awake time since this one.
406    #[must_use]
407    pub fn elapsed(self) -> Duration {
408        Self::now().duration_since(self)
409    }
410
411    /// The instant `duration` earlier, if the clock reaches back that far.
412    #[must_use]
413    pub fn checked_sub(self, duration: Duration) -> Option<Self> {
414        #[cfg(windows)]
415        {
416            let units = u64::try_from(duration.as_nanos() / 100).ok()?;
417            self.0.checked_sub(units).map(Self)
418        }
419        #[cfg(not(windows))]
420        {
421            self.0.checked_sub(duration).map(Self)
422        }
423    }
424}
425
426/// Whether `path` has the macOS `com.apple.quarantine` attribute, under which Gatekeeper refuses
427/// to load an unnotarized library. `false` on other systems and for unreadable files.
428#[must_use]
429pub fn quarantined(path: &Path) -> bool {
430    #[cfg(target_os = "macos")]
431    {
432        platform::quarantined(path)
433    }
434    #[cfg(not(target_os = "macos"))]
435    {
436        let _ = path;
437        false
438    }
439}
440
441/// The arguments as UTF-8 strings. Unlike `std::env::args`, a non-UTF-8 argument is an error,
442/// not a panic.
443///
444/// # Errors
445/// A message naming the first non-UTF-8 argument, lossily decoded.
446pub fn utf8_arguments(
447    arguments: impl Iterator<Item = std::ffi::OsString>,
448) -> Result<Vec<String>, String> {
449    arguments
450        .map(|argument| {
451            argument.into_string().map_err(|argument| {
452                format!(
453                    "argument {:?} is not valid UTF-8",
454                    argument.to_string_lossy()
455                )
456            })
457        })
458        .collect()
459}
460
461/// The process's memory, as the operating system counts it.
462///
463/// Resident memory includes shared pages. The footprint is what memory limits and the OOM killer
464/// use: `RssAnon + RssShmem` on Linux (peak from `VmHWM`, which also counts file pages),
465/// `ri_phys_footprint` on macOS, `PrivateUsage` on Windows (peak `PeakPagefileUsage`).
466#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
467pub struct ProcessMemory {
468    /// Resident bytes now (the working set on Windows).
469    pub resident_bytes: Option<u64>,
470    /// Peak resident bytes so far.
471    pub peak_resident_bytes: Option<u64>,
472    /// Footprint bytes now.
473    pub footprint_bytes: Option<u64>,
474    /// Peak footprint bytes so far.
475    pub peak_footprint_bytes: Option<u64>,
476}
477
478/// The `major.minor.build.revision` from the Windows version resource of `path`; `None` elsewhere
479/// or without one.
480#[must_use]
481pub fn file_version(path: &Path) -> Option<String> {
482    #[cfg(windows)]
483    {
484        platform::file_version(path)
485    }
486    #[cfg(not(windows))]
487    {
488        let _ = path;
489        None
490    }
491}
492
493/// The process's memory now and at its peak; unavailable figures are `None`.
494#[must_use]
495pub fn process_memory() -> ProcessMemory {
496    #[cfg(any(unix, windows))]
497    {
498        platform::process_memory()
499    }
500    #[cfg(not(any(unix, windows)))]
501    {
502        ProcessMemory::default()
503    }
504}
505
506/// The calling thread's CPU work so far, comparable only with other readings on the same thread:
507/// CPU nanoseconds on Unix, cycles on Windows (`QueryThreadCycleTime`). Excludes time spent
508/// waiting for a CPU. `None` where unavailable.
509#[must_use]
510pub fn thread_cpu_work() -> Option<u64> {
511    #[cfg(any(unix, windows))]
512    {
513        platform::thread_cpu_work()
514    }
515    #[cfg(not(any(unix, windows)))]
516    {
517        None
518    }
519}
520
521/// Asks the scheduler to keep the calling thread on the fastest cores, so [`thread_cpu_work`]
522/// readings come from one kind of core: `QOS_CLASS_USER_INTERACTIVE` on macOS, no `EcoQoS`
523/// throttling on Windows, nothing elsewhere. Best effort; failures are silent.
524pub fn prefer_fast_cores() {
525    #[cfg(any(target_os = "macos", windows))]
526    platform::prefer_fast_cores();
527}
528
529/// Caps glibc's malloc arenas at `arenas` unless `MALLOC_ARENA_MAX` is set. Does nothing elsewhere.
530///
531/// Long-lived allocations spread over many per-thread arenas pin their pages, so a process that
532/// starts threads per request keeps growing. The cap applies to arenas created after the call.
533pub fn limit_heap_arenas(arenas: u16) {
534    #[cfg(all(target_os = "linux", target_env = "gnu"))]
535    platform::limit_heap_arenas(arenas);
536    #[cfg(not(all(target_os = "linux", target_env = "gnu")))]
537    let _ = arenas;
538}
539
540/// User plus system CPU time of all the process's threads so far; `None` where unavailable.
541#[must_use]
542pub fn process_cpu_time() -> Option<std::time::Duration> {
543    #[cfg(any(unix, windows))]
544    {
545        platform::process_cpu_time()
546    }
547    #[cfg(not(any(unix, windows)))]
548    {
549        None
550    }
551}
552
553/// Writes one line to standard error like `eprintln!`, but drops it instead of panicking when the
554/// stream is closed, so a client closing the pipe cannot abort an export. Use [`diagnostic!`].
555pub fn write_diagnostic(line: std::fmt::Arguments<'_>) {
556    use std::io::Write;
557    let _ = writeln!(io::stderr().lock(), "{line}");
558}
559
560/// `eprintln!` that drops the line instead of panicking ([`write_diagnostic`]).
561#[macro_export]
562macro_rules! diagnostic {
563    ($($argument:tt)*) => {
564        $crate::write_diagnostic(::std::format_args!($($argument)*))
565    };
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use std::fs::{self, OpenOptions};
572
573    #[test]
574    fn the_awake_clock_advances_with_time_and_never_goes_back() {
575        let started = AwakeInstant::now();
576        std::thread::sleep(Duration::from_millis(200));
577        let elapsed = started.elapsed();
578        assert!(
579            elapsed >= Duration::from_millis(150) && elapsed < Duration::from_secs(5),
580            "{elapsed:?} for a 200 ms sleep"
581        );
582        let mut last = AwakeInstant::now();
583        for _ in 0..10_000 {
584            let now = AwakeInstant::now();
585            assert!(now >= last, "{now:?} after {last:?}");
586            last = now;
587        }
588        assert_eq!(
589            started
590                .checked_sub(Duration::from_millis(1))
591                .map(|earlier| started.duration_since(earlier)),
592            Some(Duration::from_millis(1))
593        );
594    }
595
596    #[test]
597    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
598    fn the_process_reports_its_resident_memory() {
599        let memory = process_memory();
600        let resident = memory.resident_bytes.expect("resident bytes");
601        let peak = memory.peak_resident_bytes.expect("peak resident bytes");
602        assert!(resident > 1 << 20, "{memory:?}");
603        assert!(peak >= resident, "{memory:?}");
604    }
605
606    #[test]
607    #[cfg(any(unix, windows))]
608    fn the_process_cpu_time_grows_with_its_work() {
609        let started = process_cpu_time().expect("process CPU time");
610        let mut state = 1_u64;
611        let busy = std::time::Instant::now();
612        while busy.elapsed() < std::time::Duration::from_millis(200) {
613            state = std::hint::black_box(state.wrapping_mul(6_364_136_223_846_793_005));
614        }
615        let worked = process_cpu_time()
616            .expect("process CPU time")
617            .saturating_sub(started);
618        // Windows CPU clocks tick in steps of up to 16 ms.
619        assert!(
620            worked >= std::time::Duration::from_millis(150),
621            "{worked:?} of CPU time for 200 ms of work"
622        );
623    }
624
625    #[test]
626    #[cfg(any(unix, windows))]
627    fn the_thread_counts_the_work_it_does_and_not_the_time_it_sleeps() {
628        let started = thread_cpu_work().expect("thread work");
629        std::thread::sleep(std::time::Duration::from_millis(200));
630        let slept = thread_cpu_work().expect("thread work") - started;
631        let mut state = 1_u64;
632        let busy = std::time::Instant::now();
633        while busy.elapsed() < std::time::Duration::from_millis(200) {
634            state = std::hint::black_box(state.wrapping_mul(6_364_136_223_846_793_005));
635        }
636        let worked = thread_cpu_work().expect("thread work") - started - slept;
637        assert!(worked > slept * 4, "slept {slept}, worked {worked}");
638    }
639
640    fn scratch(name: &str) -> std::path::PathBuf {
641        let path = std::env::temp_dir().join(format!("alpha2-os-{}-{name}", std::process::id()));
642        let _ = fs::remove_file(&path);
643        path
644    }
645
646    fn open(path: &Path) -> File {
647        OpenOptions::new()
648            .read(true)
649            .write(true)
650            .create(true)
651            .truncate(false)
652            .open(path)
653            .expect("open")
654    }
655
656    /// Serializes lock-release tests with the test that spawns a child: between `fork` and `exec`
657    /// the child holds copies of every descriptor, keeping their locks alive.
658    pub(crate) static FORK: std::sync::Mutex<()> = std::sync::Mutex::new(());
659
660    #[test]
661    fn one_handle_at_a_time_holds_the_lock_and_closing_it_releases_it() {
662        let _fork = FORK
663            .lock()
664            .unwrap_or_else(std::sync::PoisonError::into_inner);
665        let path = scratch("lock");
666        let first = open(&path);
667        let second = open(&path);
668        assert!(try_lock_exclusive(&first).expect("lock"));
669        assert!(!try_lock_exclusive(&second).expect("second lock"));
670        drop(first);
671        assert!(try_lock_exclusive(&second).expect("lock after release"));
672        let _ = fs::remove_file(&path);
673    }
674
675    #[test]
676    fn another_handle_writes_the_whole_of_a_locked_file() {
677        use std::io::{Read, Write};
678        let path = scratch("write");
679        let lock = open(&path);
680        assert!(try_lock_exclusive(&lock).expect("lock"));
681        let mut writer = File::create(&path).expect("writer truncates and opens");
682        writer
683            .write_all(&[7_u8; 4096])
684            .expect("write under the lock");
685        drop(writer);
686        let mut read = Vec::new();
687        File::open(&path)
688            .expect("reader")
689            .read_to_end(&mut read)
690            .expect("read under the lock");
691        assert_eq!(read, vec![7_u8; 4096]);
692        drop(lock);
693        let _ = fs::remove_file(&path);
694    }
695
696    /// Names the file [`lock_probe_child`] tries to lock.
697    const LOCK_PROBE: &str = "ALPHA2_OS_LOCK_PROBE";
698
699    /// Whether a child process ([`lock_probe_child`]) can take the lock on `path`.
700    fn another_process_takes_the_lock(path: &Path) -> bool {
701        let status = std::process::Command::new(std::env::current_exe().expect("test binary"))
702            .args([
703                "--exact",
704                "tests::lock_probe_child",
705                "--ignored",
706                "--test-threads=1",
707            ])
708            .env(LOCK_PROBE, path)
709            .stdout(std::process::Stdio::null())
710            .status()
711            .expect("run the lock probe");
712        match status.code() {
713            Some(0) => true,
714            Some(3) => false,
715            _ => panic!("the lock probe failed: {status}"),
716        }
717    }
718
719    /// Child side of [`another_process_takes_the_lock`]: exits `0` if it got the lock, `3` if not.
720    #[test]
721    #[ignore = "run by another_process_takes_the_lock"]
722    fn lock_probe_child() {
723        let path = std::env::var_os(LOCK_PROBE).expect("the parent names the file");
724        let locked = try_lock_exclusive(&open(Path::new(&path))).expect("probe lock");
725        std::process::exit(if locked { 0 } else { 3 });
726    }
727
728    /// `FFmpeg` opens and closes its own handles on the locked file; a per-process `fcntl` lock
729    /// would be released by the first close.
730    #[test]
731    fn the_lock_stays_with_its_handle_when_another_handle_closes() {
732        let _fork = FORK
733            .lock()
734            .unwrap_or_else(std::sync::PoisonError::into_inner);
735        let path = scratch("other-handle");
736        let lock = open(&path);
737        assert!(try_lock_exclusive(&lock).expect("lock"));
738        drop(File::create(&path).expect("another handle opens and closes"));
739        drop(open(&path));
740        assert!(
741            !another_process_takes_the_lock(&path),
742            "closing another handle released the lock"
743        );
744        drop(lock);
745        assert!(
746            another_process_takes_the_lock(&path),
747            "closing the handle that holds the lock releases it"
748        );
749        let _ = fs::remove_file(&path);
750    }
751
752    /// The Linux lock covers only the byte at `i64::MAX`, so CIFS still lets `FFmpeg` write the
753    /// whole file.
754    #[test]
755    #[cfg(target_os = "linux")]
756    fn the_lock_covers_only_one_byte_no_write_reaches() {
757        use std::os::fd::AsRawFd;
758        let path = scratch("one-byte");
759        let lock = open(&path);
760        assert!(try_lock_exclusive(&lock).expect("lock"));
761        let other = open(&path);
762        let probe = |start: i64, len: i64| {
763            // SAFETY: an all-zero `flock` is valid; the fields the query reads are set below.
764            let mut query: libc::flock = unsafe { std::mem::zeroed() };
765            query.l_type = libc::c_short::try_from(libc::F_WRLCK).expect("F_WRLCK");
766            query.l_whence = libc::c_short::try_from(libc::SEEK_SET).expect("SEEK_SET");
767            query.l_start = start;
768            query.l_len = len;
769            // SAFETY: `other` is open for the call and `query` outlives it.
770            let asked =
771                unsafe { libc::fcntl(other.as_raw_fd(), libc::F_OFD_GETLK, &raw mut query) };
772            assert_eq!(asked, 0, "F_OFD_GETLK: {}", io::Error::last_os_error());
773            query
774        };
775        let everything_below = probe(0, i64::MAX);
776        assert_eq!(
777            i32::from(everything_below.l_type),
778            libc::F_UNLCK,
779            "no byte a write can reach is locked"
780        );
781        let last = probe(0, 0);
782        assert_eq!(
783            i32::from(last.l_type),
784            libc::F_WRLCK,
785            "the lock is an OFD lock"
786        );
787        // The kernel reports a lock reaching the last offset with `l_len` 0 (to end of file).
788        assert_eq!(last.l_start, i64::MAX);
789        drop(lock);
790        let _ = fs::remove_file(&path);
791    }
792
793    #[test]
794    fn a_path_moved_away_and_created_again_is_another_file() {
795        let (path, moved) = (scratch("identity"), scratch("identity-moved"));
796        let file = open(&path);
797        assert!(same_file(&file, &path).expect("same"));
798        fs::rename(&path, &moved).expect("move away");
799        assert!(!same_file(&file, &path).expect("missing"));
800        drop(open(&path));
801        assert!(!same_file(&file, &path).expect("replaced"));
802        assert!(same_file(&file, &moved).expect("moved"));
803        let _ = fs::remove_file(&path);
804        let _ = fs::remove_file(&moved);
805    }
806
807    #[test]
808    fn a_hard_link_and_a_path_through_a_linked_directory_are_the_same_file() {
809        let directory = scratch("identity-paths");
810        let _ = fs::remove_dir_all(&directory);
811        fs::create_dir(&directory).expect("directory");
812        let (file, copy, link) = (
813            directory.join("file"),
814            directory.join("copy"),
815            directory.join("link"),
816        );
817        fs::write(&file, b"source").expect("file");
818        fs::write(&copy, b"source").expect("copy");
819        fs::hard_link(&file, &link).expect("hard link");
820        assert!(same_file_at(&file, &file).expect("itself"));
821        assert!(same_file_at(&file, &link).expect("hard link"));
822        assert!(!same_file_at(&file, &copy).expect("copy"));
823        assert!(!same_file_at(&file, &directory.join("missing")).expect("missing"));
824        let linked = scratch("identity-paths-linked");
825        let _ = fs::remove_file(&linked);
826        #[cfg(unix)]
827        std::os::unix::fs::symlink(&directory, &linked).expect("symlink");
828        // Windows symlinks need developer mode or admin; a junction needs no privilege.
829        #[cfg(windows)]
830        {
831            let _fork = FORK
832                .lock()
833                .unwrap_or_else(std::sync::PoisonError::into_inner);
834            let status = std::process::Command::new("cmd")
835                .arg("/C")
836                .arg("mklink")
837                .arg("/J")
838                .arg(&linked)
839                .arg(&directory)
840                .stdout(std::process::Stdio::null())
841                .status()
842                .expect("run mklink");
843            assert!(status.success(), "mklink /J failed: {status}");
844        }
845        assert!(same_file_at(&file, &linked.join("file")).expect("through the link"));
846        assert!(!same_file_at(&copy, &linked.join("file")).expect("another file"));
847        #[cfg(unix)]
848        fs::remove_file(&linked).expect("cleanup");
849        #[cfg(windows)]
850        fs::remove_dir(&linked).expect("cleanup");
851        fs::remove_dir_all(&directory).expect("cleanup");
852    }
853
854    #[test]
855    fn a_removed_file_still_open_is_not_the_file_its_path_names() {
856        // SMB keeps a removed file's name until its last handle closes.
857        let path = scratch("identity-removed");
858        let file = open(&path);
859        fs::remove_file(&path).expect("remove");
860        assert!(!same_file(&file, &path).expect("removed"));
861        drop(file);
862        let again = open(&path);
863        assert!(same_file(&again, &path).expect("created again"));
864        drop(again);
865        let _ = fs::remove_file(&path);
866    }
867
868    #[test]
869    fn a_publish_moves_a_file_to_a_free_name_and_never_replaces_one() {
870        let (from, to) = (scratch("publish-from"), scratch("publish-to"));
871        fs::write(&from, b"export").expect("from");
872        fs::write(&to, b"someone else's").expect("to");
873        let refused = publish_no_replace(&from, &to, &RetryPolicy::NONE)
874            .expect_err("an existing file is not replaced");
875        assert_eq!(refused.kind(), io::ErrorKind::AlreadyExists);
876        assert_eq!(fs::read(&to).expect("to"), b"someone else's");
877        assert_eq!(fs::read(&from).expect("from"), b"export");
878        fs::remove_file(&to).expect("free the name");
879        // Expect the placeholder method on file systems that refuse the no-replace flag (exFAT).
880        let (probe_from, probe_to) = (scratch("publish-probe-from"), scratch("publish-probe-to"));
881        fs::write(&probe_from, b"probe").expect("probe");
882        let method = match platform::rename_exclusive(&probe_from, &probe_to) {
883            Err(error) if platform::flag_refused(&error) => PublishMethod::PlaceholderRename,
884            answer => {
885                answer.expect("the no-replace rename");
886                PublishMethod::RenameNoReplace
887            }
888        };
889        let _ = (fs::remove_file(&probe_from), fs::remove_file(&probe_to));
890        let published =
891            publish_no_replace(&from, &to, &RetryPolicy::NONE).expect("publish to a free name");
892        assert_eq!(published, Published { method, retries: 0 });
893        assert_eq!(fs::read(&to).expect("to"), b"export");
894        assert!(!from.exists());
895        let _ = fs::remove_file(&to);
896    }
897
898    #[test]
899    fn a_replacing_publish_moves_a_file_over_another() {
900        let (from, to) = (scratch("replace-from"), scratch("replace-to"));
901        fs::write(&from, b"export").expect("from");
902        fs::write(&to, b"old export").expect("to");
903        let published = publish_replace(&from, &to, &RetryPolicy::NONE).expect("replace");
904        assert_eq!(published.method, PublishMethod::RenameReplace);
905        assert_eq!(fs::read(&to).expect("to"), b"export");
906        assert!(!from.exists());
907        let _ = fs::remove_file(&to);
908    }
909
910    /// The error a file system without a no-replace rename returns.
911    fn flag_refused_error() -> io::Error {
912        #[cfg(unix)]
913        return io::Error::from_raw_os_error(libc::EINVAL);
914        #[cfg(windows)]
915        return io::Error::from_raw_os_error(50);
916    }
917
918    #[test]
919    fn a_file_system_without_the_no_replace_flag_publishes_through_a_placeholder() {
920        let (from, to) = (scratch("placeholder-from"), scratch("placeholder-to"));
921        let _ = fs::remove_file(&to);
922        fs::write(&from, b"export").expect("from");
923        let no_flag = |_: &Path, _: &Path| Err(flag_refused_error());
924        let published = publish_no_replace_with(&from, &to, &RetryPolicy::NONE, &no_flag)
925            .expect("publish through a placeholder");
926        assert_eq!(published.method, PublishMethod::PlaceholderRename);
927        assert_eq!(fs::read(&to).expect("to"), b"export");
928        assert!(!from.exists());
929        fs::write(&from, b"second export").expect("from");
930        let refused = publish_no_replace_with(&from, &to, &RetryPolicy::NONE, &no_flag)
931            .expect_err("an existing file is not replaced");
932        assert_eq!(refused.kind(), io::ErrorKind::AlreadyExists);
933        assert_eq!(fs::read(&to).expect("to"), b"export");
934        assert_eq!(fs::read(&from).expect("from"), b"second export");
935        let _ = fs::remove_file(&from);
936        let _ = fs::remove_file(&to);
937    }
938
939    /// A second name for a published file would let an overwrite of either name truncate the
940    /// other.
941    #[test]
942    #[cfg(unix)]
943    fn a_publish_never_leaves_a_second_link_to_the_published_file() {
944        use std::os::unix::fs::MetadataExt;
945        let (from, to) = (scratch("one-link-from"), scratch("one-link-to"));
946        let _ = fs::remove_file(&to);
947        fs::write(&from, b"export").expect("from");
948        let no_flag = |_: &Path, _: &Path| Err(flag_refused_error());
949        publish_no_replace_with(&from, &to, &RetryPolicy::NONE, &no_flag).expect("publish");
950        assert_eq!(fs::metadata(&to).expect("to").nlink(), 1);
951        assert!(!from.exists());
952        let _ = fs::remove_file(&to);
953    }
954
955    #[test]
956    fn a_failure_another_handle_causes_is_tried_again_until_the_budget_ends() {
957        let busy = || io::Error::from(io::ErrorKind::ResourceBusy);
958        let retryable = |error: &io::Error| error.kind() == io::ErrorKind::ResourceBusy;
959        let policy = RetryPolicy {
960            budget: Duration::from_secs(10),
961            on_wait: &|| true,
962        };
963        let mut tries = 0;
964        let (value, retries) = retrying(&policy, retryable, || {
965            tries += 1;
966            if tries < 3 { Err(busy()) } else { Ok(tries) }
967        })
968        .expect("the third try succeeds");
969        assert_eq!((value, retries), (3, 2));
970
971        let short = RetryPolicy {
972            budget: Duration::from_millis(200),
973            on_wait: &|| true,
974        };
975        let started = Instant::now();
976        let error = retrying(&short, retryable, || Err::<(), _>(busy())).expect_err("never");
977        assert_eq!(error.kind(), io::ErrorKind::ResourceBusy);
978        let elapsed = started.elapsed();
979        assert!(
980            elapsed >= Duration::from_millis(200) && elapsed < Duration::from_secs(5),
981            "{elapsed:?}"
982        );
983
984        let cancelled = RetryPolicy {
985            budget: Duration::from_secs(10),
986            on_wait: &|| false,
987        };
988        let mut tries = 0;
989        assert!(
990            retrying(&cancelled, retryable, || {
991                tries += 1;
992                Err::<(), _>(busy())
993            })
994            .is_err()
995        );
996        assert_eq!(tries, 1, "a cancelled wait stops trying");
997
998        let mut tries = 0;
999        assert!(
1000            retrying(&policy, retryable, || {
1001                tries += 1;
1002                Err::<(), _>(io::Error::from(io::ErrorKind::NotFound))
1003            })
1004            .is_err()
1005        );
1006        assert_eq!(tries, 1, "another error is not tried again");
1007    }
1008
1009    #[test]
1010    #[cfg(unix)]
1011    fn a_directory_syncs() {
1012        sync_directory(&std::env::temp_dir()).expect("sync the temporary directory");
1013    }
1014
1015    #[test]
1016    fn the_name_limit_of_the_temporary_directory_is_known() {
1017        let limit = name_limit(&std::env::temp_dir());
1018        assert!(limit.max >= 143, "{limit:?}");
1019        assert_eq!(limit.length("ab"), 2);
1020        assert_eq!(
1021            limit.length("\u{d55c}"),
1022            if limit.unit == NameUnit::Bytes { 3 } else { 1 }
1023        );
1024    }
1025
1026    /// A directory whose paths exceed `MAX_PATH` once a file name is added.
1027    #[cfg(windows)]
1028    fn long_directory(label: &str) -> std::path::PathBuf {
1029        let root = scratch(label);
1030        let _ = fs::remove_dir_all(&root);
1031        let directory = root.join("a".repeat(120)).join("b".repeat(120));
1032        fs::create_dir_all(&directory).expect("long directory");
1033        directory
1034    }
1035
1036    #[test]
1037    #[cfg(windows)]
1038    fn a_publish_reaches_a_path_longer_than_max_path() {
1039        let directory = long_directory("long-path");
1040        let (from, to) = (directory.join("from.mp4"), directory.join("to.mp4"));
1041        assert!(!to.as_os_str().to_string_lossy().starts_with(r"\\?\"));
1042        assert!(to.as_os_str().len() > 260);
1043        fs::write(&from, b"export").expect("from");
1044        let published = publish_no_replace(&from, &to, &RetryPolicy::NONE).expect("no replace");
1045        assert_eq!(published.method, PublishMethod::RenameNoReplace);
1046        fs::write(&from, b"second export").expect("from");
1047        publish_replace(&from, &to, &RetryPolicy::NONE).expect("replace");
1048        assert_eq!(fs::read(&to).expect("to"), b"second export");
1049        let _ = fs::remove_dir_all(scratch("long-path"));
1050    }
1051
1052    /// Opens `path` with `FILE_SHARE_READ` only, as media players and scanners do.
1053    #[cfg(windows)]
1054    fn hold_without_delete_sharing(path: &Path) -> File {
1055        use std::os::windows::fs::OpenOptionsExt;
1056        OpenOptions::new()
1057            .read(true)
1058            .share_mode(1)
1059            .open(path)
1060            .expect("hold the file")
1061    }
1062
1063    #[test]
1064    #[cfg(windows)]
1065    fn a_publish_waits_for_a_reader_without_delete_sharing() {
1066        let policy = RetryPolicy {
1067            budget: Duration::from_secs(10),
1068            on_wait: &|| true,
1069        };
1070        let (from, to) = (scratch("held-from"), scratch("held-to"));
1071        let _ = fs::remove_file(&to);
1072        fs::write(&from, b"export").expect("from");
1073        fs::write(&to, b"old export").expect("to");
1074        let holder = hold_without_delete_sharing(&to);
1075        let released = std::thread::spawn(move || {
1076            std::thread::sleep(Duration::from_millis(300));
1077            drop(holder);
1078        });
1079        let published = publish_replace(&from, &to, &policy).expect("replace once released");
1080        released.join().expect("holder");
1081        assert!(published.retries >= 1, "{published:?}");
1082        assert_eq!(fs::read(&to).expect("to"), b"export");
1083        fs::remove_file(&to).expect("free the name");
1084
1085        fs::write(&from, b"export").expect("from");
1086        let holder = hold_without_delete_sharing(&from);
1087        let released = std::thread::spawn(move || {
1088            std::thread::sleep(Duration::from_millis(300));
1089            drop(holder);
1090        });
1091        let published = publish_no_replace(&from, &to, &policy).expect("publish once released");
1092        released.join().expect("holder");
1093        assert!(published.retries >= 1, "{published:?}");
1094        fs::remove_file(&to).expect("free the name");
1095
1096        fs::write(&from, b"export").expect("from");
1097        let holder = hold_without_delete_sharing(&from);
1098        let short = RetryPolicy {
1099            budget: Duration::from_millis(500),
1100            on_wait: &|| true,
1101        };
1102        let error = publish_no_replace(&from, &to, &short).expect_err("never released");
1103        assert!(is_retryable_publish_error(&error), "{error}");
1104        drop(holder);
1105        assert!(from.exists(), "a refused publish keeps its source");
1106        let _ = fs::remove_file(&from);
1107    }
1108
1109    #[test]
1110    #[cfg(windows)]
1111    fn two_names_of_one_file_have_one_identity_and_two_files_two() {
1112        let directory = scratch("file-id");
1113        let _ = fs::remove_dir_all(&directory);
1114        fs::create_dir(&directory).expect("directory");
1115        let (file, link, copy) = (
1116            directory.join("file"),
1117            directory.join("link"),
1118            directory.join("copy"),
1119        );
1120        fs::write(&file, b"source").expect("file");
1121        fs::write(&copy, b"source").expect("copy");
1122        fs::hard_link(&file, &link).expect("hard link");
1123        let id = |path: &Path| {
1124            platform::file_id_info(&File::open(path).expect("open")).expect("FileIdInfo")
1125        };
1126        assert_eq!(id(&file), id(&link));
1127        assert_ne!(id(&file), id(&copy));
1128        assert_eq!(
1129            platform::identity(&File::open(&file).expect("open")).expect("identity"),
1130            id(&file),
1131            "NTFS answers FileIdInfo, so the 64-bit index is not used"
1132        );
1133        fs::remove_dir_all(&directory).expect("cleanup");
1134    }
1135}