1use 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
21pub const PARTIAL_OUTPUT_IN_USE: &str = "another export is writing this output";
24
25fn 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
36pub enum ExistingOutput {
37 #[default]
39 Refuse,
40 Replace,
43}
44
45#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct OutputTarget {
48 pub path: PathBuf,
50 pub existing: ExistingOutput,
52}
53
54impl OutputTarget {
55 #[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
110const CLAIM_ATTEMPTS: usize = 8;
113
114pub(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
141type Lock<'a> = &'a mut dyn FnMut(&File) -> std::io::Result<bool>;
144
145fn 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 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 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
196fn 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
217pub(crate) struct PartialOutput {
221 path: PathBuf,
222 lock: Option<PartialLock>,
223}
224
225pub(crate) struct PartialLock {
229 _file: File,
230}
231
232impl PartialOutput {
233 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 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 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 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 pub(crate) fn path(&self) -> &Path {
283 &self.path
284 }
285
286 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 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 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 if let Some(lock) = self.lock.take() {
326 let _ = fs::remove_file(&self.path);
327 drop(lock);
328 }
329 }
330}
331
332pub(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 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
363fn 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
378pub 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 drop(partial.keep());
403 Ok(())
404}
405
406pub 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
442pub 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 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 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 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}