use std::fs::File;
use std::fs::OpenOptions;
use std::fs::TryLockError;
use std::io::Read as _;
use std::io::Seek as _;
use std::io::Write as _;
use std::path::Path;
use std::path::PathBuf;
use super::ProjectError;
use crate::Lockfile;
#[derive(Debug)]
pub struct LockedLockfile {
path: PathBuf,
file: File,
}
impl LockedLockfile {
pub fn read(path: &Path) -> Result<Option<Lockfile>, ProjectError> {
let file = match File::open(path) {
Ok(file) => file,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(ProjectError::Io {
path: path.to_path_buf(),
source,
});
}
};
wait_for_lock(path, || file.try_lock_shared(), || file.lock_shared())?;
parse(&file, path)
}
pub fn acquire(path: &Path) -> Result<Self, ProjectError> {
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(path)
.map_err(|source| ProjectError::Io {
path: path.to_path_buf(),
source,
})?;
wait_for_lock(path, || file.try_lock(), || file.lock())?;
Ok(Self {
path: path.to_path_buf(),
file,
})
}
pub fn current(&self) -> Result<Option<Lockfile>, ProjectError> {
parse(&self.file, &self.path)
}
pub fn write(self, lockfile: &Lockfile) -> Result<(), ProjectError> {
let mut bytes = Vec::new();
lockfile
.write(&mut bytes)
.map_err(|source| ProjectError::Io {
path: self.path.clone(),
source,
})?;
let mut file = &self.file;
file.rewind().map_err(|source| ProjectError::Io {
path: self.path.clone(),
source,
})?;
file.set_len(0).map_err(|source| ProjectError::Io {
path: self.path.clone(),
source,
})?;
file.write_all(&bytes).map_err(|source| ProjectError::Io {
path: self.path.clone(),
source,
})
}
}
fn wait_for_lock(
path: &Path,
try_lock: impl FnOnce() -> Result<(), TryLockError>,
lock: impl FnOnce() -> std::io::Result<()>,
) -> Result<(), ProjectError> {
match try_lock() {
Ok(()) => Ok(()),
Err(TryLockError::WouldBlock) => {
#[cfg(feature = "git-resolver")]
tracing::info!(
lockfile = %path.display(),
"waiting to acquire the module lockfile lock"
);
lock().map_err(|source| ProjectError::Io {
path: path.to_path_buf(),
source,
})
}
Err(TryLockError::Error(source)) => Err(ProjectError::Io {
path: path.to_path_buf(),
source,
}),
}
}
fn parse(file: &File, path: &Path) -> Result<Option<Lockfile>, ProjectError> {
let mut handle = file;
handle.rewind().map_err(|source| ProjectError::Io {
path: path.to_path_buf(),
source,
})?;
let mut bytes = Vec::new();
handle
.read_to_end(&mut bytes)
.map_err(|source| ProjectError::Io {
path: path.to_path_buf(),
source,
})?;
if bytes.is_empty() {
return Ok(None);
}
Lockfile::parse(&bytes)
.map(Some)
.map_err(|source| ProjectError::Lockfile {
path: path.to_path_buf(),
source,
})
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
use super::*;
const LOCKFILE: &[u8] = br#"{"version":1,"dependencies":{}}"#;
type Result = std::result::Result<(), Box<dyn std::error::Error>>;
fn lockfile_path(root: &Path) -> std::path::PathBuf {
root.join(crate::LOCKFILE_FILENAME)
}
#[test]
fn read_reports_an_absent_lockfile_as_none() -> Result {
let directory = tempfile::tempdir()?;
let path = lockfile_path(directory.path());
assert!(LockedLockfile::read(&path)?.is_none());
assert!(
!path.exists(),
"reading must never create `module-lock.json`"
);
Ok(())
}
#[test]
fn read_parses_a_present_lockfile() -> Result {
let directory = tempfile::tempdir()?;
let path = lockfile_path(directory.path());
std::fs::write(&path, LOCKFILE)?;
assert_eq!(
LockedLockfile::read(&path)?.map(|lockfile| lockfile.version),
Some(crate::lockfile::LOCKFILE_VERSION)
);
Ok(())
}
#[test]
fn read_reports_an_empty_lockfile_as_none() -> Result {
let directory = tempfile::tempdir()?;
let path = lockfile_path(directory.path());
std::fs::write(&path, b"")?;
assert!(LockedLockfile::read(&path)?.is_none());
Ok(())
}
#[cfg(unix)]
#[test]
fn write_keeps_the_locked_inode() -> Result {
use std::os::unix::fs::MetadataExt as _;
let directory = tempfile::tempdir()?;
let path = lockfile_path(directory.path());
std::fs::write(&path, LOCKFILE)?;
let before = std::fs::metadata(&path)?.ino();
LockedLockfile::acquire(&path)?.write(&Lockfile::default())?;
assert_eq!(
std::fs::metadata(&path)?.ino(),
before,
"writing through the held handle must not replace the inode"
);
Ok(())
}
#[test]
fn write_replaces_longer_previous_contents() -> Result {
let directory = tempfile::tempdir()?;
let path = lockfile_path(directory.path());
std::fs::write(&path, [LOCKFILE, b" "].concat())?;
LockedLockfile::acquire(&path)?.write(&Lockfile::default())?;
assert_eq!(
LockedLockfile::read(&path)?.map(|lockfile| lockfile.version),
Some(crate::lockfile::LOCKFILE_VERSION)
);
Ok(())
}
#[test]
fn acquire_serializes_concurrent_writers() -> Result {
let directory = tempfile::tempdir()?;
let path = lockfile_path(directory.path());
let first = LockedLockfile::acquire(&path)?;
let (sender, receiver) = mpsc::channel();
let thread = std::thread::spawn({
let path = path.clone();
move || {
sender.send(LockedLockfile::acquire(&path).is_ok()).unwrap();
}
});
assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err());
drop(first);
assert!(receiver.recv_timeout(Duration::from_secs(5))?);
thread.join().unwrap();
Ok(())
}
#[test]
fn current_sees_what_is_on_disk_under_the_lock() -> Result {
let directory = tempfile::tempdir()?;
let path = lockfile_path(directory.path());
std::fs::write(&path, LOCKFILE)?;
let guard = LockedLockfile::acquire(&path)?;
assert_eq!(
guard.current()?.map(|lockfile| lockfile.version),
Some(crate::lockfile::LOCKFILE_VERSION)
);
Ok(())
}
}