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
34const CLAIM_ATTEMPTS: usize = 8;
37
38pub(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
65type Lock<'a> = &'a mut dyn FnMut(&File) -> std::io::Result<bool>;
68
69fn 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 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 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
120fn 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
141pub(crate) struct PartialOutput {
145 path: PathBuf,
146 lock: Option<PartialLock>,
147}
148
149pub(crate) struct PartialLock {
153 _file: File,
154}
155
156impl PartialOutput {
157 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 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 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 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 pub(crate) fn path(&self) -> &Path {
202 &self.path
203 }
204
205 pub(crate) fn open_muxer(&self, container: Container) -> Result<OutputFile, EncodeError> {
213 let text = self
214 .path
215 .to_str()
216 .ok_or_else(|| EncodeError::Output("output path is not UTF-8".into()))?;
217 let name = CString::new(text)
218 .map_err(|_| EncodeError::Output("output path contains a NUL byte".into()))?;
219 OutputFile::create(&name, crate::convert::muxer_name(container)).map_err(output_error)
220 }
221
222 pub(crate) fn open_file(&self) -> Result<File, EncodeError> {
227 OpenOptions::new()
228 .write(true)
229 .open(&self.path)
230 .map_err(output_error)
231 }
232
233 pub(crate) fn keep(mut self) -> PartialLock {
236 let lock = self.lock.take();
237 lock.unwrap_or_else(|| unreachable!("a claimed partial holds its lock until it is kept"))
238 }
239}
240
241impl Drop for PartialOutput {
242 fn drop(&mut self) {
243 if let Some(lock) = self.lock.take() {
245 let _ = fs::remove_file(&self.path);
246 drop(lock);
247 }
248 }
249}
250
251pub(crate) fn publish(partial_path: &Path, final_path: &Path) -> Result<(), EncodeError> {
259 alpha2_os::rename_no_replace(partial_path, final_path).map_err(|error| {
260 if error.kind() == std::io::ErrorKind::AlreadyExists {
261 EncodeError::Output(format!(
262 "a file appeared at {} while this export ran; it was left as it is",
263 final_path.display()
264 ))
265 } else {
266 output_error_at(final_path, error)
267 }
268 })
269}
270
271fn ensure_replaceable(final_path: &Path) -> Result<(), EncodeError> {
275 match fs::symlink_metadata(final_path) {
276 Ok(metadata) if metadata.file_type().is_file() => Ok(()),
277 Ok(_) => Err(EncodeError::Output(format!(
278 "refusing to replace {}: not a regular file",
279 final_path.display()
280 ))),
281 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
282 Err(error) => Err(output_error_at(final_path, error)),
283 }
284}
285
286pub fn write_replacing(
300 final_path: impl AsRef<Path>,
301 write: impl FnOnce(&mut File) -> Result<(), String>,
302) -> Result<(), EncodeError> {
303 let final_path = final_path.as_ref();
304 ensure_replaceable(final_path)?;
305 let partial = PartialOutput::claim_partial(final_path)?;
306 let mut file = partial.open_file()?;
307 write(&mut file).map_err(|error| output_error_at(partial.path(), error))?;
308 file.sync_all()
309 .map_err(|error| output_error_at(partial.path(), error))?;
310 drop(file);
311 ensure_replaceable(final_path)?;
312 fs::rename(partial.path(), final_path).map_err(|error| output_error_at(final_path, error))?;
313 drop(partial.keep());
315 Ok(())
316}
317
318pub fn remove_stale_partial_output(path: impl AsRef<Path>) -> Result<bool, EncodeError> {
328 let partial = partial_output_path(path.as_ref())?;
329 let Some((file, _)) = lock_partial(&partial, false, &mut alpha2_os::try_lock_exclusive)? else {
330 return Ok(false);
331 };
332 let removed = fs::remove_file(&partial);
333 drop(file);
334 match removed {
335 Ok(()) => Ok(true),
336 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
337 Err(error) => Err(output_error(error)),
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use std::io::{Read, Write};
344
345 use super::*;
346
347 fn final_path(label: &str) -> PathBuf {
350 let path =
351 std::env::temp_dir().join(format!("alpha2-partial-{label}-{}.mp4", std::process::id()));
352 let _ = fs::remove_file(&path);
353 let _ = fs::remove_file(partial_output_path(&path).expect("partial path"));
354 path
355 }
356
357 fn no_locks(_: &File) -> std::io::Result<bool> {
359 Err(std::io::Error::other("No locks available"))
360 }
361
362 #[test]
363 fn a_partial_the_claim_created_and_could_not_lock_is_removed() {
364 let path = final_path("unlockable");
365 let partial = partial_output_path(&path).expect("partial path");
366 let Err(error) = PartialOutput::claim_partial_locking(&path, &mut no_locks) else {
367 panic!("a claim without a lock succeeded");
368 };
369 assert!(
370 matches!(&error, EncodeError::Output(message) if message.contains(&partial.display().to_string())),
371 "{error}"
372 );
373 assert!(
374 !partial.exists(),
375 "the refused claim left the partial it created"
376 );
377 }
378
379 #[test]
380 fn a_partial_that_was_there_is_left_when_the_lock_fails() {
381 let path = final_path("unlockable-existing");
382 let partial = partial_output_path(&path).expect("partial path");
383 fs::write(&partial, b"another export's").expect("existing partial");
384 assert!(PartialOutput::claim_partial_locking(&path, &mut no_locks).is_err());
385 assert_eq!(
386 fs::read(&partial).expect("still there"),
387 b"another export's"
388 );
389 fs::remove_file(&partial).expect("clean up");
390 }
391
392 #[test]
393 fn a_partial_its_holder_removes_while_it_is_claimed_is_claimed_new() {
394 let path = final_path("removed-while-claimed");
398 let partial = partial_output_path(&path).expect("partial path");
399 let mut holder = Some(PartialOutput::claim(&path).expect("holder"));
400 let claimed = PartialOutput::claim_partial_locking(&path, &mut |file| {
401 drop(holder.take());
402 alpha2_os::try_lock_exclusive(file)
403 })
404 .expect("claimed after the holder finished");
405 assert!(holder.is_none());
406 let mut writer = claimed.open_file().expect("the claimed partial opens");
407 writer.write_all(b"new export").expect("write");
408 drop(writer);
409 let mut read = String::new();
410 File::open(&partial)
411 .and_then(|mut file| file.read_to_string(&mut read))
412 .expect("read back");
413 assert_eq!(read, "new export");
414 drop(claimed);
415 assert!(!partial.exists());
416 }
417}