use std::ffi::CString;
use std::fs::{self, File, OpenOptions};
use std::path::{Path, PathBuf};
use alpha2_ffmpeg_bridge::OutputFile;
use alpha2_media::{Container, EncodeError};
pub const PARTIAL_OUTPUT_IN_USE: &str = "another export is writing this output";
fn output_error(error: impl std::fmt::Display) -> EncodeError {
EncodeError::Output(error.to_string())
}
fn output_error_at(path: &Path, error: impl std::fmt::Display) -> EncodeError {
EncodeError::Output(format!("{}: {error}", path.display()))
}
const CLAIM_ATTEMPTS: usize = 8;
pub(crate) fn partial_output_path(final_path: &Path) -> Result<PathBuf, EncodeError> {
let name = final_path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| EncodeError::Output("output path has no UTF-8 file name".into()))?;
let extension = final_path
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or("bin");
let stem = final_path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or(name);
Ok(final_path.with_file_name(format!("{stem}.alpha2-partial.{extension}")))
}
fn in_use(partial: &Path) -> EncodeError {
EncodeError::OutputInUse(format!(
"{PARTIAL_OUTPUT_IN_USE}: its partial file {} is held by an export still running",
partial.display()
))
}
type Lock<'a> = &'a mut dyn FnMut(&File) -> std::io::Result<bool>;
fn lock_partial(
path: &Path,
create: bool,
lock: Lock<'_>,
) -> Result<Option<(File, bool)>, EncodeError> {
let at = |error: std::io::Error| output_error_at(path, error);
for _ in 0..CLAIM_ATTEMPTS {
let Some((file, created)) = open_partial(path, create).map_err(at)? else {
if create {
continue;
}
return Ok(None);
};
let locked = match lock(&file) {
Ok(locked) => locked,
Err(error) => {
if created && alpha2_os::same_file(&file, path).unwrap_or(false) {
let _ = fs::remove_file(path);
}
return Err(at(error));
}
};
if !locked {
return Err(in_use(path));
}
if alpha2_os::same_file(&file, path).map_err(at)? {
return Ok(Some((file, created)));
}
}
Err(EncodeError::Output(format!(
"the partial file {} kept changing while it was claimed",
path.display()
)))
}
fn open_partial(path: &Path, create: bool) -> std::io::Result<Option<(File, bool)>> {
let mut options = OpenOptions::new();
options.read(true).write(true);
if create {
match options.clone().create_new(true).open(path) {
Ok(file) => return Ok(Some((file, true))),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(error),
}
}
match options.open(path) {
Ok(file) => Ok(Some((file, false))),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
pub(crate) struct PartialOutput {
path: PathBuf,
lock: Option<PartialLock>,
}
pub(crate) struct PartialLock {
_file: File,
}
impl PartialOutput {
pub(crate) fn claim(final_path: &Path) -> Result<Self, EncodeError> {
if final_path.exists() {
return Err(EncodeError::Output(
"refusing to overwrite an existing output file".into(),
));
}
Self::claim_partial(final_path)
}
fn claim_partial(final_path: &Path) -> Result<Self, EncodeError> {
Self::claim_partial_locking(final_path, &mut alpha2_os::try_lock_exclusive)
}
fn claim_partial_locking(final_path: &Path, lock: Lock<'_>) -> Result<Self, EncodeError> {
let path = partial_output_path(final_path)?;
let (file, created) = lock_partial(&path, true, lock)?
.ok_or_else(|| EncodeError::Output("the partial file was not created".into()))?;
if !created {
alpha2_os::diagnostic!(
"alpha2: replacing {}, a partial file no running export holds",
path.display()
);
}
let emptied = file.set_len(0);
let claimed = Self {
path,
lock: Some(PartialLock { _file: file }),
};
emptied.map_err(|error| output_error_at(&claimed.path, error))?;
Ok(claimed)
}
pub(crate) fn path(&self) -> &Path {
&self.path
}
pub(crate) fn open_muxer(&self, container: Container) -> Result<OutputFile, EncodeError> {
let text = self
.path
.to_str()
.ok_or_else(|| EncodeError::Output("output path is not UTF-8".into()))?;
let name = CString::new(text)
.map_err(|_| EncodeError::Output("output path contains a NUL byte".into()))?;
OutputFile::create(&name, crate::convert::muxer_name(container)).map_err(output_error)
}
pub(crate) fn open_file(&self) -> Result<File, EncodeError> {
OpenOptions::new()
.write(true)
.open(&self.path)
.map_err(output_error)
}
pub(crate) fn keep(mut self) -> PartialLock {
let lock = self.lock.take();
lock.unwrap_or_else(|| unreachable!("a claimed partial holds its lock until it is kept"))
}
}
impl Drop for PartialOutput {
fn drop(&mut self) {
if let Some(lock) = self.lock.take() {
let _ = fs::remove_file(&self.path);
drop(lock);
}
}
}
pub(crate) fn publish(partial_path: &Path, final_path: &Path) -> Result<(), EncodeError> {
alpha2_os::rename_no_replace(partial_path, final_path).map_err(|error| {
if error.kind() == std::io::ErrorKind::AlreadyExists {
EncodeError::Output(format!(
"a file appeared at {} while this export ran; it was left as it is",
final_path.display()
))
} else {
output_error_at(final_path, error)
}
})
}
fn ensure_replaceable(final_path: &Path) -> Result<(), EncodeError> {
match fs::symlink_metadata(final_path) {
Ok(metadata) if metadata.file_type().is_file() => Ok(()),
Ok(_) => Err(EncodeError::Output(format!(
"refusing to replace {}: not a regular file",
final_path.display()
))),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(output_error_at(final_path, error)),
}
}
pub fn write_replacing(
final_path: impl AsRef<Path>,
write: impl FnOnce(&mut File) -> Result<(), String>,
) -> Result<(), EncodeError> {
let final_path = final_path.as_ref();
ensure_replaceable(final_path)?;
let partial = PartialOutput::claim_partial(final_path)?;
let mut file = partial.open_file()?;
write(&mut file).map_err(|error| output_error_at(partial.path(), error))?;
file.sync_all()
.map_err(|error| output_error_at(partial.path(), error))?;
drop(file);
ensure_replaceable(final_path)?;
fs::rename(partial.path(), final_path).map_err(|error| output_error_at(final_path, error))?;
drop(partial.keep());
Ok(())
}
pub fn remove_stale_partial_output(path: impl AsRef<Path>) -> Result<bool, EncodeError> {
let partial = partial_output_path(path.as_ref())?;
let Some((file, _)) = lock_partial(&partial, false, &mut alpha2_os::try_lock_exclusive)? else {
return Ok(false);
};
let removed = fs::remove_file(&partial);
drop(file);
match removed {
Ok(()) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(output_error(error)),
}
}
#[cfg(test)]
mod tests {
use std::io::{Read, Write};
use super::*;
fn final_path(label: &str) -> PathBuf {
let path =
std::env::temp_dir().join(format!("alpha2-partial-{label}-{}.mp4", std::process::id()));
let _ = fs::remove_file(&path);
let _ = fs::remove_file(partial_output_path(&path).expect("partial path"));
path
}
fn no_locks(_: &File) -> std::io::Result<bool> {
Err(std::io::Error::other("No locks available"))
}
#[test]
fn a_partial_the_claim_created_and_could_not_lock_is_removed() {
let path = final_path("unlockable");
let partial = partial_output_path(&path).expect("partial path");
let Err(error) = PartialOutput::claim_partial_locking(&path, &mut no_locks) else {
panic!("a claim without a lock succeeded");
};
assert!(
matches!(&error, EncodeError::Output(message) if message.contains(&partial.display().to_string())),
"{error}"
);
assert!(
!partial.exists(),
"the refused claim left the partial it created"
);
}
#[test]
fn a_partial_that_was_there_is_left_when_the_lock_fails() {
let path = final_path("unlockable-existing");
let partial = partial_output_path(&path).expect("partial path");
fs::write(&partial, b"another export's").expect("existing partial");
assert!(PartialOutput::claim_partial_locking(&path, &mut no_locks).is_err());
assert_eq!(
fs::read(&partial).expect("still there"),
b"another export's"
);
fs::remove_file(&partial).expect("clean up");
}
#[test]
fn a_partial_its_holder_removes_while_it_is_claimed_is_claimed_new() {
let path = final_path("removed-while-claimed");
let partial = partial_output_path(&path).expect("partial path");
let mut holder = Some(PartialOutput::claim(&path).expect("holder"));
let claimed = PartialOutput::claim_partial_locking(&path, &mut |file| {
drop(holder.take());
alpha2_os::try_lock_exclusive(file)
})
.expect("claimed after the holder finished");
assert!(holder.is_none());
let mut writer = claimed.open_file().expect("the claimed partial opens");
writer.write_all(b"new export").expect("write");
drop(writer);
let mut read = String::new();
File::open(&partial)
.and_then(|mut file| file.read_to_string(&mut read))
.expect("read back");
assert_eq!(read, "new export");
drop(claimed);
assert!(!partial.exists());
}
}