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/// User plus system CPU time of all the process's threads so far; `None` where unavailable.
530#[must_use]
531pub fn process_cpu_time() -> Option<std::time::Duration> {
532    #[cfg(any(unix, windows))]
533    {
534        platform::process_cpu_time()
535    }
536    #[cfg(not(any(unix, windows)))]
537    {
538        None
539    }
540}
541
542/// Writes one line to standard error like `eprintln!`, but drops it instead of panicking when the
543/// stream is closed, so a client closing the pipe cannot abort an export. Use [`diagnostic!`].
544pub fn write_diagnostic(line: std::fmt::Arguments<'_>) {
545    use std::io::Write;
546    let _ = writeln!(io::stderr().lock(), "{line}");
547}
548
549/// `eprintln!` that drops the line instead of panicking ([`write_diagnostic`]).
550#[macro_export]
551macro_rules! diagnostic {
552    ($($argument:tt)*) => {
553        $crate::write_diagnostic(::std::format_args!($($argument)*))
554    };
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use std::fs::{self, OpenOptions};
561
562    #[test]
563    fn the_awake_clock_advances_with_time_and_never_goes_back() {
564        let started = AwakeInstant::now();
565        std::thread::sleep(Duration::from_millis(200));
566        let elapsed = started.elapsed();
567        assert!(
568            elapsed >= Duration::from_millis(150) && elapsed < Duration::from_secs(5),
569            "{elapsed:?} for a 200 ms sleep"
570        );
571        let mut last = AwakeInstant::now();
572        for _ in 0..10_000 {
573            let now = AwakeInstant::now();
574            assert!(now >= last, "{now:?} after {last:?}");
575            last = now;
576        }
577        assert_eq!(
578            started
579                .checked_sub(Duration::from_millis(1))
580                .map(|earlier| started.duration_since(earlier)),
581            Some(Duration::from_millis(1))
582        );
583    }
584
585    #[test]
586    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
587    fn the_process_reports_its_resident_memory() {
588        let memory = process_memory();
589        let resident = memory.resident_bytes.expect("resident bytes");
590        let peak = memory.peak_resident_bytes.expect("peak resident bytes");
591        assert!(resident > 1 << 20, "{memory:?}");
592        assert!(peak >= resident, "{memory:?}");
593    }
594
595    #[test]
596    #[cfg(any(unix, windows))]
597    fn the_process_cpu_time_grows_with_its_work() {
598        let started = process_cpu_time().expect("process CPU time");
599        let mut state = 1_u64;
600        let busy = std::time::Instant::now();
601        while busy.elapsed() < std::time::Duration::from_millis(200) {
602            state = std::hint::black_box(state.wrapping_mul(6_364_136_223_846_793_005));
603        }
604        let worked = process_cpu_time()
605            .expect("process CPU time")
606            .saturating_sub(started);
607        // Windows CPU clocks tick in steps of up to 16 ms.
608        assert!(
609            worked >= std::time::Duration::from_millis(150),
610            "{worked:?} of CPU time for 200 ms of work"
611        );
612    }
613
614    #[test]
615    #[cfg(any(unix, windows))]
616    fn the_thread_counts_the_work_it_does_and_not_the_time_it_sleeps() {
617        let started = thread_cpu_work().expect("thread work");
618        std::thread::sleep(std::time::Duration::from_millis(200));
619        let slept = thread_cpu_work().expect("thread work") - started;
620        let mut state = 1_u64;
621        let busy = std::time::Instant::now();
622        while busy.elapsed() < std::time::Duration::from_millis(200) {
623            state = std::hint::black_box(state.wrapping_mul(6_364_136_223_846_793_005));
624        }
625        let worked = thread_cpu_work().expect("thread work") - started - slept;
626        assert!(worked > slept * 4, "slept {slept}, worked {worked}");
627    }
628
629    fn scratch(name: &str) -> std::path::PathBuf {
630        let path = std::env::temp_dir().join(format!("alpha2-os-{}-{name}", std::process::id()));
631        let _ = fs::remove_file(&path);
632        path
633    }
634
635    fn open(path: &Path) -> File {
636        OpenOptions::new()
637            .read(true)
638            .write(true)
639            .create(true)
640            .truncate(false)
641            .open(path)
642            .expect("open")
643    }
644
645    /// Serializes lock-release tests with the test that spawns a child: between `fork` and `exec`
646    /// the child holds copies of every descriptor, keeping their locks alive.
647    pub(crate) static FORK: std::sync::Mutex<()> = std::sync::Mutex::new(());
648
649    #[test]
650    fn one_handle_at_a_time_holds_the_lock_and_closing_it_releases_it() {
651        let _fork = FORK
652            .lock()
653            .unwrap_or_else(std::sync::PoisonError::into_inner);
654        let path = scratch("lock");
655        let first = open(&path);
656        let second = open(&path);
657        assert!(try_lock_exclusive(&first).expect("lock"));
658        assert!(!try_lock_exclusive(&second).expect("second lock"));
659        drop(first);
660        assert!(try_lock_exclusive(&second).expect("lock after release"));
661        let _ = fs::remove_file(&path);
662    }
663
664    #[test]
665    fn another_handle_writes_the_whole_of_a_locked_file() {
666        use std::io::{Read, Write};
667        let path = scratch("write");
668        let lock = open(&path);
669        assert!(try_lock_exclusive(&lock).expect("lock"));
670        let mut writer = File::create(&path).expect("writer truncates and opens");
671        writer
672            .write_all(&[7_u8; 4096])
673            .expect("write under the lock");
674        drop(writer);
675        let mut read = Vec::new();
676        File::open(&path)
677            .expect("reader")
678            .read_to_end(&mut read)
679            .expect("read under the lock");
680        assert_eq!(read, vec![7_u8; 4096]);
681        drop(lock);
682        let _ = fs::remove_file(&path);
683    }
684
685    /// Names the file [`lock_probe_child`] tries to lock.
686    const LOCK_PROBE: &str = "ALPHA2_OS_LOCK_PROBE";
687
688    /// Whether a child process ([`lock_probe_child`]) can take the lock on `path`.
689    fn another_process_takes_the_lock(path: &Path) -> bool {
690        let status = std::process::Command::new(std::env::current_exe().expect("test binary"))
691            .args([
692                "--exact",
693                "tests::lock_probe_child",
694                "--ignored",
695                "--test-threads=1",
696            ])
697            .env(LOCK_PROBE, path)
698            .stdout(std::process::Stdio::null())
699            .status()
700            .expect("run the lock probe");
701        match status.code() {
702            Some(0) => true,
703            Some(3) => false,
704            _ => panic!("the lock probe failed: {status}"),
705        }
706    }
707
708    /// Child side of [`another_process_takes_the_lock`]: exits `0` if it got the lock, `3` if not.
709    #[test]
710    #[ignore = "run by another_process_takes_the_lock"]
711    fn lock_probe_child() {
712        let path = std::env::var_os(LOCK_PROBE).expect("the parent names the file");
713        let locked = try_lock_exclusive(&open(Path::new(&path))).expect("probe lock");
714        std::process::exit(if locked { 0 } else { 3 });
715    }
716
717    /// `FFmpeg` opens and closes its own handles on the locked file; a per-process `fcntl` lock
718    /// would be released by the first close.
719    #[test]
720    fn the_lock_stays_with_its_handle_when_another_handle_closes() {
721        let _fork = FORK
722            .lock()
723            .unwrap_or_else(std::sync::PoisonError::into_inner);
724        let path = scratch("other-handle");
725        let lock = open(&path);
726        assert!(try_lock_exclusive(&lock).expect("lock"));
727        drop(File::create(&path).expect("another handle opens and closes"));
728        drop(open(&path));
729        assert!(
730            !another_process_takes_the_lock(&path),
731            "closing another handle released the lock"
732        );
733        drop(lock);
734        assert!(
735            another_process_takes_the_lock(&path),
736            "closing the handle that holds the lock releases it"
737        );
738        let _ = fs::remove_file(&path);
739    }
740
741    /// The Linux lock covers only the byte at `i64::MAX`, so CIFS still lets `FFmpeg` write the
742    /// whole file.
743    #[test]
744    #[cfg(target_os = "linux")]
745    fn the_lock_covers_only_one_byte_no_write_reaches() {
746        use std::os::fd::AsRawFd;
747        let path = scratch("one-byte");
748        let lock = open(&path);
749        assert!(try_lock_exclusive(&lock).expect("lock"));
750        let other = open(&path);
751        let probe = |start: i64, len: i64| {
752            // SAFETY: an all-zero `flock` is valid; the fields the query reads are set below.
753            let mut query: libc::flock = unsafe { std::mem::zeroed() };
754            query.l_type = libc::c_short::try_from(libc::F_WRLCK).expect("F_WRLCK");
755            query.l_whence = libc::c_short::try_from(libc::SEEK_SET).expect("SEEK_SET");
756            query.l_start = start;
757            query.l_len = len;
758            // SAFETY: `other` is open for the call and `query` outlives it.
759            let asked =
760                unsafe { libc::fcntl(other.as_raw_fd(), libc::F_OFD_GETLK, &raw mut query) };
761            assert_eq!(asked, 0, "F_OFD_GETLK: {}", io::Error::last_os_error());
762            query
763        };
764        let everything_below = probe(0, i64::MAX);
765        assert_eq!(
766            i32::from(everything_below.l_type),
767            libc::F_UNLCK,
768            "no byte a write can reach is locked"
769        );
770        let last = probe(0, 0);
771        assert_eq!(
772            i32::from(last.l_type),
773            libc::F_WRLCK,
774            "the lock is an OFD lock"
775        );
776        // The kernel reports a lock reaching the last offset with `l_len` 0 (to end of file).
777        assert_eq!(last.l_start, i64::MAX);
778        drop(lock);
779        let _ = fs::remove_file(&path);
780    }
781
782    #[test]
783    fn a_path_moved_away_and_created_again_is_another_file() {
784        let (path, moved) = (scratch("identity"), scratch("identity-moved"));
785        let file = open(&path);
786        assert!(same_file(&file, &path).expect("same"));
787        fs::rename(&path, &moved).expect("move away");
788        assert!(!same_file(&file, &path).expect("missing"));
789        drop(open(&path));
790        assert!(!same_file(&file, &path).expect("replaced"));
791        assert!(same_file(&file, &moved).expect("moved"));
792        let _ = fs::remove_file(&path);
793        let _ = fs::remove_file(&moved);
794    }
795
796    #[test]
797    fn a_hard_link_and_a_path_through_a_linked_directory_are_the_same_file() {
798        let directory = scratch("identity-paths");
799        let _ = fs::remove_dir_all(&directory);
800        fs::create_dir(&directory).expect("directory");
801        let (file, copy, link) = (
802            directory.join("file"),
803            directory.join("copy"),
804            directory.join("link"),
805        );
806        fs::write(&file, b"source").expect("file");
807        fs::write(&copy, b"source").expect("copy");
808        fs::hard_link(&file, &link).expect("hard link");
809        assert!(same_file_at(&file, &file).expect("itself"));
810        assert!(same_file_at(&file, &link).expect("hard link"));
811        assert!(!same_file_at(&file, &copy).expect("copy"));
812        assert!(!same_file_at(&file, &directory.join("missing")).expect("missing"));
813        let linked = scratch("identity-paths-linked");
814        let _ = fs::remove_file(&linked);
815        #[cfg(unix)]
816        std::os::unix::fs::symlink(&directory, &linked).expect("symlink");
817        // Windows symlinks need developer mode or admin; a junction needs no privilege.
818        #[cfg(windows)]
819        {
820            let _fork = FORK
821                .lock()
822                .unwrap_or_else(std::sync::PoisonError::into_inner);
823            let status = std::process::Command::new("cmd")
824                .arg("/C")
825                .arg("mklink")
826                .arg("/J")
827                .arg(&linked)
828                .arg(&directory)
829                .stdout(std::process::Stdio::null())
830                .status()
831                .expect("run mklink");
832            assert!(status.success(), "mklink /J failed: {status}");
833        }
834        assert!(same_file_at(&file, &linked.join("file")).expect("through the link"));
835        assert!(!same_file_at(&copy, &linked.join("file")).expect("another file"));
836        #[cfg(unix)]
837        fs::remove_file(&linked).expect("cleanup");
838        #[cfg(windows)]
839        fs::remove_dir(&linked).expect("cleanup");
840        fs::remove_dir_all(&directory).expect("cleanup");
841    }
842
843    #[test]
844    fn a_removed_file_still_open_is_not_the_file_its_path_names() {
845        // SMB keeps a removed file's name until its last handle closes.
846        let path = scratch("identity-removed");
847        let file = open(&path);
848        fs::remove_file(&path).expect("remove");
849        assert!(!same_file(&file, &path).expect("removed"));
850        drop(file);
851        let again = open(&path);
852        assert!(same_file(&again, &path).expect("created again"));
853        drop(again);
854        let _ = fs::remove_file(&path);
855    }
856
857    #[test]
858    fn a_publish_moves_a_file_to_a_free_name_and_never_replaces_one() {
859        let (from, to) = (scratch("publish-from"), scratch("publish-to"));
860        fs::write(&from, b"export").expect("from");
861        fs::write(&to, b"someone else's").expect("to");
862        let refused = publish_no_replace(&from, &to, &RetryPolicy::NONE)
863            .expect_err("an existing file is not replaced");
864        assert_eq!(refused.kind(), io::ErrorKind::AlreadyExists);
865        assert_eq!(fs::read(&to).expect("to"), b"someone else's");
866        assert_eq!(fs::read(&from).expect("from"), b"export");
867        fs::remove_file(&to).expect("free the name");
868        // Expect the placeholder method on file systems that refuse the no-replace flag (exFAT).
869        let (probe_from, probe_to) = (scratch("publish-probe-from"), scratch("publish-probe-to"));
870        fs::write(&probe_from, b"probe").expect("probe");
871        let method = match platform::rename_exclusive(&probe_from, &probe_to) {
872            Err(error) if platform::flag_refused(&error) => PublishMethod::PlaceholderRename,
873            answer => {
874                answer.expect("the no-replace rename");
875                PublishMethod::RenameNoReplace
876            }
877        };
878        let _ = (fs::remove_file(&probe_from), fs::remove_file(&probe_to));
879        let published =
880            publish_no_replace(&from, &to, &RetryPolicy::NONE).expect("publish to a free name");
881        assert_eq!(published, Published { method, retries: 0 });
882        assert_eq!(fs::read(&to).expect("to"), b"export");
883        assert!(!from.exists());
884        let _ = fs::remove_file(&to);
885    }
886
887    #[test]
888    fn a_replacing_publish_moves_a_file_over_another() {
889        let (from, to) = (scratch("replace-from"), scratch("replace-to"));
890        fs::write(&from, b"export").expect("from");
891        fs::write(&to, b"old export").expect("to");
892        let published = publish_replace(&from, &to, &RetryPolicy::NONE).expect("replace");
893        assert_eq!(published.method, PublishMethod::RenameReplace);
894        assert_eq!(fs::read(&to).expect("to"), b"export");
895        assert!(!from.exists());
896        let _ = fs::remove_file(&to);
897    }
898
899    /// The error a file system without a no-replace rename returns.
900    fn flag_refused_error() -> io::Error {
901        #[cfg(unix)]
902        return io::Error::from_raw_os_error(libc::EINVAL);
903        #[cfg(windows)]
904        return io::Error::from_raw_os_error(50);
905    }
906
907    #[test]
908    fn a_file_system_without_the_no_replace_flag_publishes_through_a_placeholder() {
909        let (from, to) = (scratch("placeholder-from"), scratch("placeholder-to"));
910        let _ = fs::remove_file(&to);
911        fs::write(&from, b"export").expect("from");
912        let no_flag = |_: &Path, _: &Path| Err(flag_refused_error());
913        let published = publish_no_replace_with(&from, &to, &RetryPolicy::NONE, &no_flag)
914            .expect("publish through a placeholder");
915        assert_eq!(published.method, PublishMethod::PlaceholderRename);
916        assert_eq!(fs::read(&to).expect("to"), b"export");
917        assert!(!from.exists());
918        fs::write(&from, b"second export").expect("from");
919        let refused = publish_no_replace_with(&from, &to, &RetryPolicy::NONE, &no_flag)
920            .expect_err("an existing file is not replaced");
921        assert_eq!(refused.kind(), io::ErrorKind::AlreadyExists);
922        assert_eq!(fs::read(&to).expect("to"), b"export");
923        assert_eq!(fs::read(&from).expect("from"), b"second export");
924        let _ = fs::remove_file(&from);
925        let _ = fs::remove_file(&to);
926    }
927
928    /// A second name for a published file would let an overwrite of either name truncate the
929    /// other.
930    #[test]
931    #[cfg(unix)]
932    fn a_publish_never_leaves_a_second_link_to_the_published_file() {
933        use std::os::unix::fs::MetadataExt;
934        let (from, to) = (scratch("one-link-from"), scratch("one-link-to"));
935        let _ = fs::remove_file(&to);
936        fs::write(&from, b"export").expect("from");
937        let no_flag = |_: &Path, _: &Path| Err(flag_refused_error());
938        publish_no_replace_with(&from, &to, &RetryPolicy::NONE, &no_flag).expect("publish");
939        assert_eq!(fs::metadata(&to).expect("to").nlink(), 1);
940        assert!(!from.exists());
941        let _ = fs::remove_file(&to);
942    }
943
944    #[test]
945    fn a_failure_another_handle_causes_is_tried_again_until_the_budget_ends() {
946        let busy = || io::Error::from(io::ErrorKind::ResourceBusy);
947        let retryable = |error: &io::Error| error.kind() == io::ErrorKind::ResourceBusy;
948        let policy = RetryPolicy {
949            budget: Duration::from_secs(10),
950            on_wait: &|| true,
951        };
952        let mut tries = 0;
953        let (value, retries) = retrying(&policy, retryable, || {
954            tries += 1;
955            if tries < 3 { Err(busy()) } else { Ok(tries) }
956        })
957        .expect("the third try succeeds");
958        assert_eq!((value, retries), (3, 2));
959
960        let short = RetryPolicy {
961            budget: Duration::from_millis(200),
962            on_wait: &|| true,
963        };
964        let started = Instant::now();
965        let error = retrying(&short, retryable, || Err::<(), _>(busy())).expect_err("never");
966        assert_eq!(error.kind(), io::ErrorKind::ResourceBusy);
967        let elapsed = started.elapsed();
968        assert!(
969            elapsed >= Duration::from_millis(200) && elapsed < Duration::from_secs(5),
970            "{elapsed:?}"
971        );
972
973        let cancelled = RetryPolicy {
974            budget: Duration::from_secs(10),
975            on_wait: &|| false,
976        };
977        let mut tries = 0;
978        assert!(
979            retrying(&cancelled, retryable, || {
980                tries += 1;
981                Err::<(), _>(busy())
982            })
983            .is_err()
984        );
985        assert_eq!(tries, 1, "a cancelled wait stops trying");
986
987        let mut tries = 0;
988        assert!(
989            retrying(&policy, retryable, || {
990                tries += 1;
991                Err::<(), _>(io::Error::from(io::ErrorKind::NotFound))
992            })
993            .is_err()
994        );
995        assert_eq!(tries, 1, "another error is not tried again");
996    }
997
998    #[test]
999    #[cfg(unix)]
1000    fn a_directory_syncs() {
1001        sync_directory(&std::env::temp_dir()).expect("sync the temporary directory");
1002    }
1003
1004    #[test]
1005    fn the_name_limit_of_the_temporary_directory_is_known() {
1006        let limit = name_limit(&std::env::temp_dir());
1007        assert!(limit.max >= 143, "{limit:?}");
1008        assert_eq!(limit.length("ab"), 2);
1009        assert_eq!(
1010            limit.length("\u{d55c}"),
1011            if limit.unit == NameUnit::Bytes { 3 } else { 1 }
1012        );
1013    }
1014
1015    /// A directory whose paths exceed `MAX_PATH` once a file name is added.
1016    #[cfg(windows)]
1017    fn long_directory(label: &str) -> std::path::PathBuf {
1018        let root = scratch(label);
1019        let _ = fs::remove_dir_all(&root);
1020        let directory = root.join("a".repeat(120)).join("b".repeat(120));
1021        fs::create_dir_all(&directory).expect("long directory");
1022        directory
1023    }
1024
1025    #[test]
1026    #[cfg(windows)]
1027    fn a_publish_reaches_a_path_longer_than_max_path() {
1028        let directory = long_directory("long-path");
1029        let (from, to) = (directory.join("from.mp4"), directory.join("to.mp4"));
1030        assert!(!to.as_os_str().to_string_lossy().starts_with(r"\\?\"));
1031        assert!(to.as_os_str().len() > 260);
1032        fs::write(&from, b"export").expect("from");
1033        let published = publish_no_replace(&from, &to, &RetryPolicy::NONE).expect("no replace");
1034        assert_eq!(published.method, PublishMethod::RenameNoReplace);
1035        fs::write(&from, b"second export").expect("from");
1036        publish_replace(&from, &to, &RetryPolicy::NONE).expect("replace");
1037        assert_eq!(fs::read(&to).expect("to"), b"second export");
1038        let _ = fs::remove_dir_all(scratch("long-path"));
1039    }
1040
1041    /// Opens `path` with `FILE_SHARE_READ` only, as media players and scanners do.
1042    #[cfg(windows)]
1043    fn hold_without_delete_sharing(path: &Path) -> File {
1044        use std::os::windows::fs::OpenOptionsExt;
1045        OpenOptions::new()
1046            .read(true)
1047            .share_mode(1)
1048            .open(path)
1049            .expect("hold the file")
1050    }
1051
1052    #[test]
1053    #[cfg(windows)]
1054    fn a_publish_waits_for_a_reader_without_delete_sharing() {
1055        let policy = RetryPolicy {
1056            budget: Duration::from_secs(10),
1057            on_wait: &|| true,
1058        };
1059        let (from, to) = (scratch("held-from"), scratch("held-to"));
1060        let _ = fs::remove_file(&to);
1061        fs::write(&from, b"export").expect("from");
1062        fs::write(&to, b"old export").expect("to");
1063        let holder = hold_without_delete_sharing(&to);
1064        let released = std::thread::spawn(move || {
1065            std::thread::sleep(Duration::from_millis(300));
1066            drop(holder);
1067        });
1068        let published = publish_replace(&from, &to, &policy).expect("replace once released");
1069        released.join().expect("holder");
1070        assert!(published.retries >= 1, "{published:?}");
1071        assert_eq!(fs::read(&to).expect("to"), b"export");
1072        fs::remove_file(&to).expect("free the name");
1073
1074        fs::write(&from, b"export").expect("from");
1075        let holder = hold_without_delete_sharing(&from);
1076        let released = std::thread::spawn(move || {
1077            std::thread::sleep(Duration::from_millis(300));
1078            drop(holder);
1079        });
1080        let published = publish_no_replace(&from, &to, &policy).expect("publish once released");
1081        released.join().expect("holder");
1082        assert!(published.retries >= 1, "{published:?}");
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 short = RetryPolicy {
1088            budget: Duration::from_millis(500),
1089            on_wait: &|| true,
1090        };
1091        let error = publish_no_replace(&from, &to, &short).expect_err("never released");
1092        assert!(is_retryable_publish_error(&error), "{error}");
1093        drop(holder);
1094        assert!(from.exists(), "a refused publish keeps its source");
1095        let _ = fs::remove_file(&from);
1096    }
1097
1098    #[test]
1099    #[cfg(windows)]
1100    fn two_names_of_one_file_have_one_identity_and_two_files_two() {
1101        let directory = scratch("file-id");
1102        let _ = fs::remove_dir_all(&directory);
1103        fs::create_dir(&directory).expect("directory");
1104        let (file, link, copy) = (
1105            directory.join("file"),
1106            directory.join("link"),
1107            directory.join("copy"),
1108        );
1109        fs::write(&file, b"source").expect("file");
1110        fs::write(&copy, b"source").expect("copy");
1111        fs::hard_link(&file, &link).expect("hard link");
1112        let id = |path: &Path| {
1113            platform::file_id_info(&File::open(path).expect("open")).expect("FileIdInfo")
1114        };
1115        assert_eq!(id(&file), id(&link));
1116        assert_ne!(id(&file), id(&copy));
1117        assert_eq!(
1118            platform::identity(&File::open(&file).expect("open")).expect("identity"),
1119            id(&file),
1120            "NTFS answers FileIdInfo, so the 64-bit index is not used"
1121        );
1122        fs::remove_dir_all(&directory).expect("cleanup");
1123    }
1124}