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::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/// How many times a claim opens the partial again when the file it locked
35/// was removed or replaced under it, by the export that held it finishing.
36const CLAIM_ATTEMPTS: usize = 8;
37
38/// `<stem>.alpha2-partial.<ext>` beside `final_path` (`bin` when it has no extension).
39///
40/// # Errors
41/// When the file name is not UTF-8.
42pub(crate) fn partial_output_path(final_path: &Path) -> Result<PathBuf, EncodeError> {
43    let name = final_path
44        .file_name()
45        .and_then(|name| name.to_str())
46        .ok_or_else(|| EncodeError::Output("output path has no UTF-8 file name".into()))?;
47    let extension = final_path
48        .extension()
49        .and_then(|extension| extension.to_str())
50        .unwrap_or("bin");
51    let stem = final_path
52        .file_stem()
53        .and_then(|stem| stem.to_str())
54        .unwrap_or(name);
55    Ok(final_path.with_file_name(format!("{stem}.alpha2-partial.{extension}")))
56}
57
58fn in_use(partial: &Path) -> EncodeError {
59    EncodeError::OutputInUse(format!(
60        "{PARTIAL_OUTPUT_IN_USE}: its partial file {} is held by an export still running",
61        partial.display()
62    ))
63}
64
65/// Locks a partial file for the claim that opened it: `Ok(false)` when
66/// another handle holds it. [`alpha2_os::try_lock_exclusive`] outside tests.
67type Lock<'a> = &'a mut dyn FnMut(&File) -> std::io::Result<bool>;
68
69/// The partial file at `path`, opened and locked by this process, and
70/// whether this call created it; `None` when it does not exist and `create`
71/// is false.
72///
73/// A file this call created and then could not lock (a file system without
74/// working locks) is removed before the error returns, as a failed export
75/// removes its partial; one that was there before is left as it is, since it
76/// may be another export's.
77///
78/// # Errors
79/// [`PARTIAL_OUTPUT_IN_USE`] when another open handle holds its lock, or the
80/// file system's error, naming the path.
81fn lock_partial(
82    path: &Path,
83    create: bool,
84    lock: Lock<'_>,
85) -> Result<Option<(File, bool)>, EncodeError> {
86    let at = |error: std::io::Error| output_error_at(path, error);
87    for _ in 0..CLAIM_ATTEMPTS {
88        let Some((file, created)) = open_partial(path, create).map_err(at)? else {
89            if create {
90                // Removed between two opens, or a name an SMB server keeps until the last handle of
91                // a removed file closes: look again.
92                continue;
93            }
94            return Ok(None);
95        };
96        let locked = match lock(&file) {
97            Ok(locked) => locked,
98            Err(error) => {
99                if created && alpha2_os::same_file(&file, path).unwrap_or(false) {
100                    let _ = fs::remove_file(path);
101                }
102                return Err(at(error));
103            }
104        };
105        if !locked {
106            return Err(in_use(path));
107        }
108        // The export that held it may have removed or renamed it between the open and the lock; the
109        // lock is then on a file that path no longer names.
110        if alpha2_os::same_file(&file, path).map_err(at)? {
111            return Ok(Some((file, created)));
112        }
113    }
114    Err(EncodeError::Output(format!(
115        "the partial file {} kept changing while it was claimed",
116        path.display()
117    )))
118}
119
120/// The partial file at `path` opened for reading and writing, and whether
121/// this call created it; `None` when nothing is there (and, with `create`,
122/// when what is there could be neither created nor opened: it went between
123/// the two).
124fn open_partial(path: &Path, create: bool) -> std::io::Result<Option<(File, bool)>> {
125    let mut options = OpenOptions::new();
126    options.read(true).write(true);
127    if create {
128        match options.clone().create_new(true).open(path) {
129            Ok(file) => return Ok(Some((file, true))),
130            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
131            Err(error) => return Err(error),
132        }
133    }
134    match options.open(path) {
135        Ok(file) => Ok(Some((file, false))),
136        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
137        Err(error) => Err(error),
138    }
139}
140
141/// A partial file a constructor is creating: removed when the constructor
142/// fails on any path after it was claimed, until [`Self::keep`] hands its
143/// lock to the value whose own `Drop` removes it from then on.
144pub(crate) struct PartialOutput {
145    path: PathBuf,
146    lock: Option<PartialLock>,
147}
148
149/// The lock on a partial file, held by the output writing it. Kept until the
150/// output has renamed the file into place or removed it, so no other export
151/// takes the file over while it is still being written.
152pub(crate) struct PartialLock {
153    _file: File,
154}
155
156impl PartialOutput {
157    /// Claims the partial file of an output at `final_path`: refuses an
158    /// existing output, creates the partial and locks it, or takes over a
159    /// stale one, emptied.
160    ///
161    /// # Errors
162    /// For an existing output, a partial a live export holds
163    /// ([`PARTIAL_OUTPUT_IN_USE`]), a path without a UTF-8 file name, or a
164    /// file-system failure.
165    pub(crate) fn claim(final_path: &Path) -> Result<Self, EncodeError> {
166        if final_path.exists() {
167            return Err(EncodeError::Output(
168                "refusing to overwrite an existing output file".into(),
169            ));
170        }
171        Self::claim_partial(final_path)
172    }
173
174    /// [`Self::claim`] without looking at what is at `final_path`.
175    fn claim_partial(final_path: &Path) -> Result<Self, EncodeError> {
176        Self::claim_partial_locking(final_path, &mut alpha2_os::try_lock_exclusive)
177    }
178
179    /// [`Self::claim_partial`], locking with `lock`.
180    fn claim_partial_locking(final_path: &Path, lock: Lock<'_>) -> Result<Self, EncodeError> {
181        let path = partial_output_path(final_path)?;
182        let (file, created) = lock_partial(&path, true, lock)?
183            .ok_or_else(|| EncodeError::Output("the partial file was not created".into()))?;
184        if !created {
185            alpha2_os::diagnostic!(
186                "alpha2: replacing {}, a partial file no running export holds",
187                path.display()
188            );
189        }
190        let emptied = file.set_len(0);
191        // From here a failure removes the file, the stale one included.
192        let claimed = Self {
193            path,
194            lock: Some(PartialLock { _file: file }),
195        };
196        emptied.map_err(|error| output_error_at(&claimed.path, error))?;
197        Ok(claimed)
198    }
199
200    /// The partial file's path.
201    pub(crate) fn path(&self) -> &Path {
202        &self.path
203    }
204
205    /// Opens the partial file for an `FFmpeg` muxer to write. Declared after
206    /// `self`, the output closes the file before `self` removes it.
207    ///
208    /// # Errors
209    /// When the path is not UTF-8 or has a NUL byte, or the muxer cannot be
210    /// created.
211    pub(crate) fn open_muxer(&self) -> Result<OutputFile, EncodeError> {
212        let text = self
213            .path
214            .to_str()
215            .ok_or_else(|| EncodeError::Output("output path is not UTF-8".into()))?;
216        let name = CString::new(text)
217            .map_err(|_| EncodeError::Output("output path contains a NUL byte".into()))?;
218        OutputFile::create(&name).map_err(output_error)
219    }
220
221    /// Opens the partial file for this process to write.
222    ///
223    /// # Errors
224    /// When the file system refuses.
225    pub(crate) fn open_file(&self) -> Result<File, EncodeError> {
226        OpenOptions::new()
227            .write(true)
228            .open(&self.path)
229            .map_err(output_error)
230    }
231
232    /// The constructor succeeded: its value owns the file now, and holds the
233    /// lock until it has renamed or removed it.
234    pub(crate) fn keep(mut self) -> PartialLock {
235        let lock = self.lock.take();
236        lock.unwrap_or_else(|| unreachable!("a claimed partial holds its lock until it is kept"))
237    }
238}
239
240impl Drop for PartialOutput {
241    fn drop(&mut self) {
242        // Removed while still locked, so no other export takes over a file about to go.
243        if let Some(lock) = self.lock.take() {
244            let _ = fs::remove_file(&self.path);
245            drop(lock);
246        }
247    }
248}
249
250/// Renames a finished partial file into place at `final_path`, which must
251/// still be free: a file that appeared there while the export ran (another
252/// program saved one, or another export of the same path finished first) is
253/// left as it is, and the export fails rather than replace it.
254///
255/// # Errors
256/// When a file is at `final_path`, or the rename fails.
257pub(crate) fn publish(partial_path: &Path, final_path: &Path) -> Result<(), EncodeError> {
258    alpha2_os::rename_no_replace(partial_path, final_path).map_err(|error| {
259        if error.kind() == std::io::ErrorKind::AlreadyExists {
260            EncodeError::Output(format!(
261                "a file appeared at {} while this export ran; it was left as it is",
262                final_path.display()
263            ))
264        } else {
265            output_error_at(final_path, error)
266        }
267    })
268}
269
270/// Refuses a `final_path` that names something other than a regular file:
271/// a symbolic link, a device, a FIFO, a socket or a directory is never
272/// renamed over. Nothing there is fine.
273fn ensure_replaceable(final_path: &Path) -> Result<(), EncodeError> {
274    match fs::symlink_metadata(final_path) {
275        Ok(metadata) if metadata.file_type().is_file() => Ok(()),
276        Ok(_) => Err(EncodeError::Output(format!(
277            "refusing to replace {}: not a regular file",
278            final_path.display()
279        ))),
280        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
281        Err(error) => Err(output_error_at(final_path, error)),
282    }
283}
284
285/// Writes a whole file at `final_path`, replacing a regular file there only
286/// once `write` has written all of it: `write` fills the claimed partial file
287/// beside it (the same claim and lock a video output takes), which is synced
288/// and renamed into place. Any failure removes the partial and leaves what was
289/// at `final_path`. Only a regular file is replaced; anything else there is
290/// refused before anything is written (and again just before the rename, as
291/// it may have changed meanwhile; what changes between that look and the
292/// rename is not guarded against, as the caller chose the path).
293///
294/// # Errors
295/// When `final_path` names something other than a regular file, its partial
296/// is held by a live export ([`PARTIAL_OUTPUT_IN_USE`]), `write` fails, or
297/// the file system refuses.
298pub fn write_replacing(
299    final_path: impl AsRef<Path>,
300    write: impl FnOnce(&mut File) -> Result<(), String>,
301) -> Result<(), EncodeError> {
302    let final_path = final_path.as_ref();
303    ensure_replaceable(final_path)?;
304    let partial = PartialOutput::claim_partial(final_path)?;
305    let mut file = partial.open_file()?;
306    write(&mut file).map_err(|error| output_error_at(partial.path(), error))?;
307    file.sync_all()
308        .map_err(|error| output_error_at(partial.path(), error))?;
309    drop(file);
310    ensure_replaceable(final_path)?;
311    fs::rename(partial.path(), final_path).map_err(|error| output_error_at(final_path, error))?;
312    // Renamed: nothing is left to remove, and the lock goes with the file it was on.
313    drop(partial.keep());
314    Ok(())
315}
316
317/// Removes a sibling partial file left by an interrupted transactional export.
318///
319/// A partial file a live export holds is not touched: the lock that tells a
320/// stale partial from a live one is the same one an output takes over a stale
321/// partial by. It returns `false` when no partial file exists.
322///
323/// # Errors
324/// [`PARTIAL_OUTPUT_IN_USE`] when a live export holds it; an encoder error for
325/// an invalid target path or a file-system failure.
326pub fn remove_stale_partial_output(path: impl AsRef<Path>) -> Result<bool, EncodeError> {
327    let partial = partial_output_path(path.as_ref())?;
328    let Some((file, _)) = lock_partial(&partial, false, &mut alpha2_os::try_lock_exclusive)? else {
329        return Ok(false);
330    };
331    let removed = fs::remove_file(&partial);
332    drop(file);
333    match removed {
334        Ok(()) => Ok(true),
335        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
336        Err(error) => Err(output_error(error)),
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use std::io::{Read, Write};
343
344    use super::*;
345
346    /// A final path in the temporary directory, unique to this test and
347    /// process, with neither it nor its partial file there.
348    fn final_path(label: &str) -> PathBuf {
349        let path =
350            std::env::temp_dir().join(format!("alpha2-partial-{label}-{}.mp4", std::process::id()));
351        let _ = fs::remove_file(&path);
352        let _ = fs::remove_file(partial_output_path(&path).expect("partial path"));
353        path
354    }
355
356    /// The lock of a file system without working locks (`ENOLCK` on Linux).
357    fn no_locks(_: &File) -> std::io::Result<bool> {
358        Err(std::io::Error::other("No locks available"))
359    }
360
361    #[test]
362    fn a_partial_the_claim_created_and_could_not_lock_is_removed() {
363        let path = final_path("unlockable");
364        let partial = partial_output_path(&path).expect("partial path");
365        let Err(error) = PartialOutput::claim_partial_locking(&path, &mut no_locks) else {
366            panic!("a claim without a lock succeeded");
367        };
368        assert!(
369            matches!(&error, EncodeError::Output(message) if message.contains(&partial.display().to_string())),
370            "{error}"
371        );
372        assert!(
373            !partial.exists(),
374            "the refused claim left the partial it created"
375        );
376    }
377
378    #[test]
379    fn a_partial_that_was_there_is_left_when_the_lock_fails() {
380        let path = final_path("unlockable-existing");
381        let partial = partial_output_path(&path).expect("partial path");
382        fs::write(&partial, b"another export's").expect("existing partial");
383        assert!(PartialOutput::claim_partial_locking(&path, &mut no_locks).is_err());
384        assert_eq!(
385            fs::read(&partial).expect("still there"),
386            b"another export's"
387        );
388        fs::remove_file(&partial).expect("clean up");
389    }
390
391    #[test]
392    fn a_partial_its_holder_removes_while_it_is_claimed_is_claimed_new() {
393        // The holder removes its partial and unlocks between this claim's open and lock. On a Linux
394        // CIFS mount the removed file keeps its name until our handle closes, so the claim must not
395        // take it.
396        let path = final_path("removed-while-claimed");
397        let partial = partial_output_path(&path).expect("partial path");
398        let mut holder = Some(PartialOutput::claim(&path).expect("holder"));
399        let claimed = PartialOutput::claim_partial_locking(&path, &mut |file| {
400            drop(holder.take());
401            alpha2_os::try_lock_exclusive(file)
402        })
403        .expect("claimed after the holder finished");
404        assert!(holder.is_none());
405        let mut writer = claimed.open_file().expect("the claimed partial opens");
406        writer.write_all(b"new export").expect("write");
407        drop(writer);
408        let mut read = String::new();
409        File::open(&partial)
410            .and_then(|mut file| file.read_to_string(&mut read))
411            .expect("read back");
412        assert_eq!(read, "new export");
413        drop(claimed);
414        assert!(!partial.exists());
415    }
416}