1use 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
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) -> 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 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 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 if let Some(lock) = self.lock.take() {
244 let _ = fs::remove_file(&self.path);
245 drop(lock);
246 }
247 }
248}
249
250pub(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
270fn 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
285pub 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 drop(partial.keep());
314 Ok(())
315}
316
317pub 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 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 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 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}