Skip to main content

alpha2_native/
partial.rs

1//! The partial file an output is written to until it is finished.
2//!
3//! Every transactional output writes `<stem>.alpha2-partial.<ext>` beside its
4//! final path and renames it into place only when it finishes; anything else
5//! removes it. A process that ends without unwinding (`SIGKILL`, the OOM
6//! killer, a crash, `TerminateProcess`) removes nothing, so the file can
7//! outlive its export. Whether a partial belongs to a live export is decided
8//! by a lock, not by the file existing: the export that creates a partial
9//! holds an exclusive advisory lock on it for as long as it writes, and the
10//! operating system drops the lock when that process ends, however it ends.
11//! A partial nobody holds is stale, and the next output to that path takes it
12//! over; one that is held is refused, naming it.
13
14use std::ffi::CString;
15use std::fs::{self, File, OpenOptions};
16use std::path::{Path, PathBuf};
17
18use alpha2_ffmpeg_bridge::OutputFile;
19use alpha2_media::{Container, EncodeError};
20
21/// The words an output refused because a live export writes its partial file
22/// ([`EncodeError::OutputInUse`]) starts with.
23pub const PARTIAL_OUTPUT_IN_USE: &str = "another export is writing this output";
24
25/// A failure to create or claim an output file, as [`EncodeError::Output`].
26fn output_error(error: impl std::fmt::Display) -> EncodeError {
27    EncodeError::Output(error.to_string())
28}
29
30fn output_error_at(path: &Path, error: impl std::fmt::Display) -> EncodeError {
31    EncodeError::Output(format!("{}: {error}", path.display()))
32}
33
34/// What an output does with a file already at its path.
35#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
36pub enum ExistingOutput {
37    /// Refuse it, before writing and again at the rename (the default).
38    #[default]
39    Refuse,
40    /// Replace a regular file there once the output is whole, by rename.
41    /// A symbolic link, a device, a FIFO, a socket or a directory is refused.
42    Replace,
43}
44
45/// Where an output goes and what it does with a file already there.
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct OutputTarget {
48    /// The output's final path.
49    pub path: PathBuf,
50    /// What happens to a file already at `path`.
51    pub existing: ExistingOutput,
52}
53
54impl OutputTarget {
55    /// `path`, replacing a regular file there.
56    #[must_use]
57    pub fn replacing(path: impl Into<PathBuf>) -> Self {
58        Self {
59            path: path.into(),
60            existing: ExistingOutput::Replace,
61        }
62    }
63}
64
65impl From<PathBuf> for OutputTarget {
66    fn from(path: PathBuf) -> Self {
67        Self {
68            path,
69            existing: ExistingOutput::Refuse,
70        }
71    }
72}
73
74impl From<&Path> for OutputTarget {
75    fn from(path: &Path) -> Self {
76        path.to_owned().into()
77    }
78}
79
80impl From<&PathBuf> for OutputTarget {
81    fn from(path: &PathBuf) -> Self {
82        path.clone().into()
83    }
84}
85
86impl From<String> for OutputTarget {
87    fn from(path: String) -> Self {
88        PathBuf::from(path).into()
89    }
90}
91
92impl From<&String> for OutputTarget {
93    fn from(path: &String) -> Self {
94        PathBuf::from(path).into()
95    }
96}
97
98impl From<&str> for OutputTarget {
99    fn from(path: &str) -> Self {
100        PathBuf::from(path).into()
101    }
102}
103
104impl From<&OutputTarget> for OutputTarget {
105    fn from(target: &OutputTarget) -> Self {
106        target.clone()
107    }
108}
109
110/// How many times a claim opens the partial again when the file it locked
111/// was removed or replaced under it, by the export that held it finishing.
112const CLAIM_ATTEMPTS: usize = 8;
113
114/// `<stem>.alpha2-partial.<ext>` beside `final_path` (`bin` when it has no extension).
115///
116/// # Errors
117/// When the file name is not UTF-8.
118pub(crate) fn partial_output_path(final_path: &Path) -> Result<PathBuf, EncodeError> {
119    let name = final_path
120        .file_name()
121        .and_then(|name| name.to_str())
122        .ok_or_else(|| EncodeError::Output("output path has no UTF-8 file name".into()))?;
123    let extension = final_path
124        .extension()
125        .and_then(|extension| extension.to_str())
126        .unwrap_or("bin");
127    let stem = final_path
128        .file_stem()
129        .and_then(|stem| stem.to_str())
130        .unwrap_or(name);
131    Ok(final_path.with_file_name(format!("{stem}.alpha2-partial.{extension}")))
132}
133
134fn in_use(partial: &Path) -> EncodeError {
135    EncodeError::OutputInUse(format!(
136        "{PARTIAL_OUTPUT_IN_USE}: its partial file {} is held by an export still running",
137        partial.display()
138    ))
139}
140
141/// Locks a partial file for the claim that opened it: `Ok(false)` when
142/// another handle holds it. [`alpha2_os::try_lock_exclusive`] outside tests.
143type Lock<'a> = &'a mut dyn FnMut(&File) -> std::io::Result<bool>;
144
145/// The partial file at `path`, opened and locked by this process, and
146/// whether this call created it; `None` when it does not exist and `create`
147/// is false.
148///
149/// A file this call created and then could not lock (a file system without
150/// working locks) is removed before the error returns, as a failed export
151/// removes its partial; one that was there before is left as it is, since it
152/// may be another export's.
153///
154/// # Errors
155/// [`PARTIAL_OUTPUT_IN_USE`] when another open handle holds its lock, or the
156/// file system's error, naming the path.
157fn lock_partial(
158    path: &Path,
159    create: bool,
160    lock: Lock<'_>,
161) -> Result<Option<(File, bool)>, EncodeError> {
162    let at = |error: std::io::Error| output_error_at(path, error);
163    for _ in 0..CLAIM_ATTEMPTS {
164        let Some((file, created)) = open_partial(path, create).map_err(at)? else {
165            if create {
166                // Removed between two opens, or a name an SMB server keeps until the last handle of
167                // a removed file closes: look again.
168                continue;
169            }
170            return Ok(None);
171        };
172        let locked = match lock(&file) {
173            Ok(locked) => locked,
174            Err(error) => {
175                if created && alpha2_os::same_file(&file, path).unwrap_or(false) {
176                    let _ = fs::remove_file(path);
177                }
178                return Err(at(error));
179            }
180        };
181        if !locked {
182            return Err(in_use(path));
183        }
184        // The export that held it may have removed or renamed it between the open and the lock; the
185        // lock is then on a file that path no longer names.
186        if alpha2_os::same_file(&file, path).map_err(at)? {
187            return Ok(Some((file, created)));
188        }
189    }
190    Err(EncodeError::Output(format!(
191        "the partial file {} kept changing while it was claimed",
192        path.display()
193    )))
194}
195
196/// The partial file at `path` opened for reading and writing, and whether
197/// this call created it; `None` when nothing is there (and, with `create`,
198/// when what is there could be neither created nor opened: it went between
199/// the two).
200fn open_partial(path: &Path, create: bool) -> std::io::Result<Option<(File, bool)>> {
201    let mut options = OpenOptions::new();
202    options.read(true).write(true);
203    if create {
204        match options.clone().create_new(true).open(path) {
205            Ok(file) => return Ok(Some((file, true))),
206            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
207            Err(error) => return Err(error),
208        }
209    }
210    match options.open(path) {
211        Ok(file) => Ok(Some((file, false))),
212        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
213        Err(error) => Err(error),
214    }
215}
216
217/// A partial file a constructor is creating: removed when the constructor
218/// fails on any path after it was claimed, until [`Self::keep`] hands its
219/// lock to the value whose own `Drop` removes it from then on.
220pub(crate) struct PartialOutput {
221    path: PathBuf,
222    lock: Option<PartialLock>,
223}
224
225/// The lock on a partial file, held by the output writing it. Kept until the
226/// output has renamed the file into place or removed it, so no other export
227/// takes the file over while it is still being written.
228pub(crate) struct PartialLock {
229    _file: File,
230}
231
232impl PartialOutput {
233    /// Claims the partial file of an output at `target`: refuses an existing
234    /// output (or, replacing, anything there but a regular file), creates the
235    /// partial and locks it, or takes over a stale one, emptied.
236    ///
237    /// # Errors
238    /// For an output the target refuses, a partial a live export holds
239    /// ([`PARTIAL_OUTPUT_IN_USE`]), a path without a UTF-8 file name, or a
240    /// file-system failure.
241    pub(crate) fn claim(target: &OutputTarget) -> Result<Self, EncodeError> {
242        match target.existing {
243            ExistingOutput::Refuse => {
244                if target.path.exists() {
245                    return Err(EncodeError::Output(
246                        "refusing to overwrite an existing output file".into(),
247                    ));
248                }
249            }
250            ExistingOutput::Replace => ensure_replaceable(&target.path)?,
251        }
252        Self::claim_partial(&target.path)
253    }
254
255    /// [`Self::claim`] without looking at what is at `final_path`.
256    fn claim_partial(final_path: &Path) -> Result<Self, EncodeError> {
257        Self::claim_partial_locking(final_path, &mut alpha2_os::try_lock_exclusive)
258    }
259
260    /// [`Self::claim_partial`], locking with `lock`.
261    fn claim_partial_locking(final_path: &Path, lock: Lock<'_>) -> Result<Self, EncodeError> {
262        let path = partial_output_path(final_path)?;
263        let (file, created) = lock_partial(&path, true, lock)?
264            .ok_or_else(|| EncodeError::Output("the partial file was not created".into()))?;
265        if !created {
266            alpha2_os::diagnostic!(
267                "alpha2: replacing {}, a partial file no running export holds",
268                path.display()
269            );
270        }
271        let emptied = file.set_len(0);
272        // From here a failure removes the file, the stale one included.
273        let claimed = Self {
274            path,
275            lock: Some(PartialLock { _file: file }),
276        };
277        emptied.map_err(|error| output_error_at(&claimed.path, error))?;
278        Ok(claimed)
279    }
280
281    /// The partial file's path.
282    pub(crate) fn path(&self) -> &Path {
283        &self.path
284    }
285
286    /// Opens the partial file for the `FFmpeg` muxer that writes `container`.
287    /// The output must drop before `self` (a local declared after it, or a
288    /// field declared before it), so the file is closed before `self` removes it.
289    ///
290    /// # Errors
291    /// When the path is not UTF-8 or has a NUL byte, or the muxer cannot be
292    /// created.
293    pub(crate) fn open_muxer(&self, container: Container) -> Result<OutputFile, EncodeError> {
294        let text = self
295            .path
296            .to_str()
297            .ok_or_else(|| EncodeError::Output("output path is not UTF-8".into()))?;
298        let name = CString::new(text)
299            .map_err(|_| EncodeError::Output("output path contains a NUL byte".into()))?;
300        OutputFile::create(&name, crate::convert::muxer_name(container)).map_err(output_error)
301    }
302
303    /// Opens the partial file for this process to write.
304    ///
305    /// # Errors
306    /// When the file system refuses.
307    pub(crate) fn open_file(&self) -> Result<File, EncodeError> {
308        OpenOptions::new()
309            .write(true)
310            .open(&self.path)
311            .map_err(output_error)
312    }
313
314    /// The constructor succeeded: its value owns the file now, and holds the
315    /// lock until it has renamed or removed it.
316    pub(crate) fn keep(mut self) -> PartialLock {
317        let lock = self.lock.take();
318        lock.unwrap_or_else(|| unreachable!("a claimed partial holds its lock until it is kept"))
319    }
320}
321
322impl Drop for PartialOutput {
323    fn drop(&mut self) {
324        // Removed while still locked, so no other export takes over a file about to go.
325        if let Some(lock) = self.lock.take() {
326            let _ = fs::remove_file(&self.path);
327            drop(lock);
328        }
329    }
330}
331
332/// Renames a finished partial file into place at `target`.
333///
334/// Refusing, the path must still be free: a file that appeared there while
335/// the export ran (another program saved one, or another export of the same
336/// path finished first) is left as it is, and the export fails rather than
337/// replace it. Replacing, a regular file there is renamed over, and anything
338/// else is refused (what changes between that look and the rename is not
339/// guarded against, as the caller chose the path).
340///
341/// # Errors
342/// When the target refuses what is at its path, or the rename fails.
343pub(crate) fn publish(partial_path: &Path, target: &OutputTarget) -> Result<(), EncodeError> {
344    let final_path = target.path.as_path();
345    if target.existing == ExistingOutput::Replace {
346        ensure_replaceable(final_path)?;
347        // `std::fs::rename` replaces a file on Windows too.
348        return fs::rename(partial_path, final_path)
349            .map_err(|error| output_error_at(final_path, error));
350    }
351    alpha2_os::rename_no_replace(partial_path, final_path).map_err(|error| {
352        if error.kind() == std::io::ErrorKind::AlreadyExists {
353            EncodeError::Output(format!(
354                "a file appeared at {} while this export ran; it was left as it is",
355                final_path.display()
356            ))
357        } else {
358            output_error_at(final_path, error)
359        }
360    })
361}
362
363/// Refuses a `final_path` that names something other than a regular file:
364/// a symbolic link, a device, a FIFO, a socket or a directory is never
365/// renamed over. Nothing there is fine.
366fn ensure_replaceable(final_path: &Path) -> Result<(), EncodeError> {
367    match fs::symlink_metadata(final_path) {
368        Ok(metadata) if metadata.file_type().is_file() => Ok(()),
369        Ok(_) => Err(EncodeError::Output(format!(
370            "refusing to replace {}: not a regular file",
371            final_path.display()
372        ))),
373        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
374        Err(error) => Err(output_error_at(final_path, error)),
375    }
376}
377
378/// Writes a whole file at `target`, published only once `write` has written
379/// all of it: `write` fills the claimed partial file beside it (the same
380/// claim and lock a video output takes), which is synced and renamed into
381/// place as [`publish`] does. Any failure removes the partial and leaves what
382/// was at the target's path. What is there is looked at before anything is
383/// written, and again at the rename, as it may have changed meanwhile.
384///
385/// # Errors
386/// When the target refuses what is at its path, its partial is held by a
387/// live export ([`PARTIAL_OUTPUT_IN_USE`]), `write` fails, or the file system
388/// refuses.
389pub fn write_whole(
390    target: impl Into<OutputTarget>,
391    write: impl FnOnce(&mut File) -> Result<(), String>,
392) -> Result<(), EncodeError> {
393    let target = target.into();
394    let partial = PartialOutput::claim(&target)?;
395    let mut file = partial.open_file()?;
396    write(&mut file).map_err(|error| output_error_at(partial.path(), error))?;
397    file.sync_all()
398        .map_err(|error| output_error_at(partial.path(), error))?;
399    drop(file);
400    publish(partial.path(), &target)?;
401    // Renamed: nothing is left to remove, and the lock goes with the file it was on.
402    drop(partial.keep());
403    Ok(())
404}
405
406/// Writes `rgba`, `width` by `height` 8-bit RGBA pixels with straight alpha,
407/// row by row, as a PNG at `target`, published as [`write_whole`] publishes a
408/// file: whole or not at all.
409///
410/// # Errors
411/// When `rgba` is not that many pixels, or as [`write_whole`].
412pub fn write_png(
413    target: impl Into<OutputTarget>,
414    width: u32,
415    height: u32,
416    rgba: &[u8],
417) -> Result<(), EncodeError> {
418    let expected = u64::from(width) * u64::from(height) * 4;
419    if u64::try_from(rgba.len()).ok() != Some(expected) {
420        return Err(EncodeError::Adapter(format!(
421            "a {width}x{height} RGBA image is {expected} bytes, not {}",
422            rgba.len()
423        )));
424    }
425    write_whole(target, |file| {
426        let mut buffered = std::io::BufWriter::new(file);
427        let mut encoder = png::Encoder::new(&mut buffered, width, height);
428        encoder.set_color(png::ColorType::Rgba);
429        encoder.set_depth(png::BitDepth::Eight);
430        let mut writer = encoder.write_header().map_err(|error| error.to_string())?;
431        writer
432            .write_image_data(rgba)
433            .map_err(|error| error.to_string())?;
434        writer.finish().map_err(|error| error.to_string())?;
435        buffered
436            .into_inner()
437            .map(|_| ())
438            .map_err(|error| error.error().to_string())
439    })
440}
441
442/// Removes a sibling partial file left by an interrupted transactional export.
443///
444/// A partial file a live export holds is not touched: the lock that tells a
445/// stale partial from a live one is the same one an output takes over a stale
446/// partial by. It returns `false` when no partial file exists.
447///
448/// # Errors
449/// [`PARTIAL_OUTPUT_IN_USE`] when a live export holds it; an encoder error for
450/// an invalid target path or a file-system failure.
451pub fn remove_stale_partial_output(path: impl AsRef<Path>) -> Result<bool, EncodeError> {
452    let partial = partial_output_path(path.as_ref())?;
453    let Some((file, _)) = lock_partial(&partial, false, &mut alpha2_os::try_lock_exclusive)? else {
454        return Ok(false);
455    };
456    let removed = fs::remove_file(&partial);
457    drop(file);
458    match removed {
459        Ok(()) => Ok(true),
460        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
461        Err(error) => Err(output_error(error)),
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use std::io::{Read, Write};
468
469    use super::*;
470
471    /// A final path in the temporary directory, unique to this test and
472    /// process, with neither it nor its partial file there.
473    fn final_path(label: &str) -> PathBuf {
474        let path =
475            std::env::temp_dir().join(format!("alpha2-partial-{label}-{}.mp4", std::process::id()));
476        let _ = fs::remove_file(&path);
477        let _ = fs::remove_file(partial_output_path(&path).expect("partial path"));
478        path
479    }
480
481    /// The lock of a file system without working locks (`ENOLCK` on Linux).
482    fn no_locks(_: &File) -> std::io::Result<bool> {
483        Err(std::io::Error::other("No locks available"))
484    }
485
486    #[test]
487    fn a_partial_the_claim_created_and_could_not_lock_is_removed() {
488        let path = final_path("unlockable");
489        let partial = partial_output_path(&path).expect("partial path");
490        let Err(error) = PartialOutput::claim_partial_locking(&path, &mut no_locks) else {
491            panic!("a claim without a lock succeeded");
492        };
493        assert!(
494            matches!(&error, EncodeError::Output(message) if message.contains(&partial.display().to_string())),
495            "{error}"
496        );
497        assert!(
498            !partial.exists(),
499            "the refused claim left the partial it created"
500        );
501    }
502
503    #[test]
504    fn a_partial_that_was_there_is_left_when_the_lock_fails() {
505        let path = final_path("unlockable-existing");
506        let partial = partial_output_path(&path).expect("partial path");
507        fs::write(&partial, b"another export's").expect("existing partial");
508        assert!(PartialOutput::claim_partial_locking(&path, &mut no_locks).is_err());
509        assert_eq!(
510            fs::read(&partial).expect("still there"),
511            b"another export's"
512        );
513        fs::remove_file(&partial).expect("clean up");
514    }
515
516    #[test]
517    fn a_partial_its_holder_removes_while_it_is_claimed_is_claimed_new() {
518        // The holder removes its partial and unlocks between this claim's open and lock. On a Linux
519        // CIFS mount the removed file keeps its name until our handle closes, so the claim must not
520        // take it.
521        let path = final_path("removed-while-claimed");
522        let partial = partial_output_path(&path).expect("partial path");
523        let mut holder = Some(PartialOutput::claim(&OutputTarget::from(&path)).expect("holder"));
524        let claimed = PartialOutput::claim_partial_locking(&path, &mut |file| {
525            drop(holder.take());
526            alpha2_os::try_lock_exclusive(file)
527        })
528        .expect("claimed after the holder finished");
529        assert!(holder.is_none());
530        let mut writer = claimed.open_file().expect("the claimed partial opens");
531        writer.write_all(b"new export").expect("write");
532        drop(writer);
533        let mut read = String::new();
534        File::open(&partial)
535            .and_then(|mut file| file.read_to_string(&mut read))
536            .expect("read back");
537        assert_eq!(read, "new export");
538        drop(claimed);
539        assert!(!partial.exists());
540    }
541}