use std::io;
use std::path::Path;
use fs4::fs_std::FileExt;
#[derive(thiserror::Error, Debug)]
pub enum CleanFileCreationError<E: std::error::Error + Send + Sync + 'static> {
#[error("The destination path is invalid (no filename)")]
InvalidPath,
#[error("The lockfile could not be created: {0}")]
LockFileCreation(io::Error),
#[error("The temporary file could not be created: {0}")]
TempFileCreation(io::Error),
#[error("The temporary file could not be locked: {0}")]
LockFileLocking(io::Error),
#[error("The callback function indicated an error: {0}")]
CallbackIndicatedError(E),
#[error("The temporary file could not be renamed to the destination file: {0}")]
RenameError(io::Error),
}
impl<E: std::error::Error + Send + Sync + 'static> From<CleanFileCreationError<E>> for io::Error {
fn from(e: CleanFileCreationError<E>) -> io::Error {
io::Error::new(io::ErrorKind::Other, e)
}
}
pub async fn create_file_cleanly<E, F, FE, G, GE, V>(
dest_path: &Path,
write_fn: F,
handle_existing_fn: FE,
) -> Result<V, CleanFileCreationError<E>>
where
E: std::error::Error + Send + Sync + 'static,
G: std::future::Future<Output = Result<V, E>>,
GE: std::future::Future<Output = Result<V, E>>,
F: FnOnce(std::fs::File) -> G,
FE: FnOnce() -> GE,
{
let Some(file_name) = dest_path.file_name() else {
return Err(CleanFileCreationError::InvalidPath);
};
let lock_file_path = dest_path.with_file_name(format!("{}.lock", file_name.to_string_lossy()));
let lock_file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&lock_file_path)
.map_err(CleanFileCreationError::LockFileCreation)?;
let locked_file = lock_file_exclusive(lock_file)
.await
.map_err(CleanFileCreationError::LockFileLocking)?;
let destination_file_exists =
matches!(std::fs::metadata(dest_path), Ok(meta) if meta.is_file());
if destination_file_exists {
drop(locked_file);
let _ = std::fs::remove_file(&lock_file_path);
let v = handle_existing_fn()
.await
.map_err(CleanFileCreationError::CallbackIndicatedError)?;
return Ok(v);
}
let temp_file_path = dest_path.with_file_name(format!("{}.part", file_name.to_string_lossy()));
let temp_file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&temp_file_path)
.map_err(CleanFileCreationError::TempFileCreation)?;
let write_result = write_fn(temp_file).await;
let v = match write_result {
Ok(v) => v,
Err(write_error) => {
let _ = std::fs::remove_file(&temp_file_path);
drop(locked_file);
return Err(CleanFileCreationError::CallbackIndicatedError(write_error));
}
};
match std::fs::rename(&temp_file_path, dest_path) {
Ok(_) => {}
Err(rename_error) => {
let _ = std::fs::remove_file(&temp_file_path);
drop(locked_file);
return Err(CleanFileCreationError::RenameError(rename_error));
}
}
drop(locked_file);
let _ = std::fs::remove_file(&lock_file_path);
Ok(v)
}
async fn lock_file_exclusive(file: std::fs::File) -> Result<std::fs::File, io::Error> {
for _ in 0..5 {
match file.try_lock_exclusive() {
Ok(true) => return Ok(file),
Ok(false) => return lock_file_exclusive_with_blocking_thread(file).await,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::new(
io::ErrorKind::Interrupted,
"File locking was interrupted too many times",
))
}
async fn lock_file_exclusive_with_blocking_thread(
file: std::fs::File,
) -> Result<std::fs::File, io::Error> {
let (tx, rx) = tokio::sync::oneshot::channel();
let thread = std::thread::Builder::new()
.name("flock".to_string())
.spawn(move || {
let locked_file_result = lock_file_exclusive_blocking_this_thread(file);
let _ = tx.send(locked_file_result);
})
.expect("couldn't create flock thread");
let locked_file = rx.await.expect("flock thread disappeared unexpectedly")?;
thread.join().expect("flock thread panicked");
Ok(locked_file)
}
fn lock_file_exclusive_blocking_this_thread(
file: std::fs::File,
) -> Result<std::fs::File, io::Error> {
for _ in 0..5 {
match file.lock_exclusive() {
Ok(()) => return Ok(file),
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::new(
io::ErrorKind::Interrupted,
"File locking was interrupted too many times",
))
}