use std::fs::{File, OpenOptions, Permissions};
use std::io;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::PathBuf;
use nix::errno::Errno;
use nix::fcntl::{Flock, FlockArg};
use crate::error::Error;
use crate::paths::Tree;
const LOCK_MODE: u32 = 0o600;
#[derive(Debug)]
pub struct Deploying {
_held: Flock<File>,
}
pub fn hold(tree: &Tree) -> Result<Deploying, Error> {
let path = tree.lock_file();
let file = open_lock_file(&path)?;
match Flock::lock(file, FlockArg::LockExclusiveNonblock) {
Ok(held) => Ok(Deploying { _held: held }),
Err((_, errno)) => Err(refusal(tree.sheep(), path, errno)),
}
}
#[derive(Debug)]
pub struct Recording {
_held: Flock<File>,
}
pub fn hold_record(tree: &Tree) -> Result<Recording, Error> {
let path = tree.record_lock();
let file = open_lock_file(&path)?;
match Flock::lock(file, FlockArg::LockExclusive) {
Ok(held) => Ok(Recording { _held: held }),
Err((_, errno)) => Err(Error::Io {
path,
source: io::Error::from_raw_os_error(errno as i32),
}),
}
}
#[derive(Debug)]
pub struct Cloning {
_held: Flock<File>,
}
pub fn hold_clone(git_dir: &std::path::Path) -> Result<Cloning, Error> {
use std::os::unix::fs::MetadataExt;
if let Some(parent) = git_dir.parent() {
std::fs::create_dir_all(parent).map_err(Error::at(parent))?;
}
let path = crate::shared::resolved(git_dir).with_extension("lock");
loop {
let file = open_lock_file(&path)?;
let held = match Flock::lock(file, FlockArg::LockExclusive) {
Ok(held) => held,
Err((_, errno)) => {
return Err(Error::Io {
path,
source: io::Error::from_raw_os_error(errno as i32),
});
}
};
let ours = held.metadata().map_err(Error::at(&path))?;
match std::fs::metadata(&path) {
Ok(at_path) if (at_path.dev(), at_path.ino()) == (ours.dev(), ours.ino()) => {
return Ok(Cloning { _held: held });
}
Ok(_) => {}
Err(source) if source.kind() == io::ErrorKind::NotFound => {}
Err(source) => return Err(Error::Io { path, source }),
}
}
}
fn open_lock_file(path: &std::path::Path) -> Result<File, Error> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(Error::at(parent))?;
}
let file = OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.mode(LOCK_MODE)
.custom_flags(crate::shared::o_nofollow())
.open(path)
.map_err(Error::at(path))?;
let _ = file.set_permissions(Permissions::from_mode(LOCK_MODE));
Ok(file)
}
fn refusal(sheep: &str, path: PathBuf, errno: Errno) -> Error {
match errno {
Errno::EWOULDBLOCK => Error::AlreadyDeploying {
sheep: sheep.to_owned(),
},
other => Error::Io {
path,
source: io::Error::from_raw_os_error(other as i32),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixtures;
#[test]
fn the_record_lock_waits_for_the_holder_and_then_takes_over() {
let home = tempfile::tempdir().expect("tempdir");
let tree = Tree::for_sheep(home.path(), "web");
let held = hold_record(&tree).expect("first hold");
let (tx, rx) = std::sync::mpsc::channel();
let waiting = {
let tree = Tree::for_sheep(home.path(), "web");
std::thread::spawn(move || {
let second = hold_record(&tree).expect("second hold");
tx.send(()).expect("report");
drop(second);
})
};
assert!(
rx.recv_timeout(std::time::Duration::from_millis(300))
.is_err(),
"the second taker must wait while the first holds"
);
drop(held);
rx.recv_timeout(std::time::Duration::from_secs(5))
.expect("the second taker gets the lock once the first lets go");
waiting.join().expect("thread");
}
use crate::paths::Tree;
#[test]
fn a_fetch_waits_for_the_clone_lock_whichever_path_names_the_clone() {
let home = tempfile::tempdir().expect("tempdir");
let origin = fixtures::checkout(&[("a", "a")]);
let remote = origin.path().to_str().expect("utf-8").to_owned();
let clone = home.path().join("repos").join("k").join("git");
crate::git::init_bare(&clone).expect("bare");
let link = home.path().join("link");
std::os::unix::fs::symlink(&clone, &link).expect("link");
let held = hold_clone(&clone).expect("first hold");
let (tx, rx) = std::sync::mpsc::channel();
let fetching = std::thread::spawn(move || {
crate::git::fetch(&link, &remote, "main", fixtures::TEST_BUDGET).expect("fetch");
tx.send(()).expect("report");
});
assert!(
rx.recv_timeout(std::time::Duration::from_millis(300))
.is_err(),
"the fetch must wait while the clone is held"
);
drop(held);
rx.recv_timeout(std::time::Duration::from_secs(30))
.expect("the fetch runs once the hold is dropped");
fetching.join().expect("thread");
}
fn third_taker_waits_for_a_waiter_that_outlasted_a_removal(recreate: bool) -> bool {
let home = tempfile::tempdir().expect("tempdir");
let dir = home.path().join("repos").join("k");
let clone = dir.join("git");
let held = hold_clone(&clone).expect("first hold");
let (acquired, hear) = std::sync::mpsc::channel();
let (release, let_go) = std::sync::mpsc::channel::<()>();
let waiter = {
let clone = clone.clone();
std::thread::spawn(move || {
let second = hold_clone(&clone).expect("second hold");
acquired.send(()).expect("report");
let_go.recv().ok();
drop(second);
})
};
std::thread::sleep(std::time::Duration::from_millis(300));
std::fs::remove_dir_all(&dir).expect("the clone is removed");
if recreate {
std::fs::create_dir_all(&dir).expect("the directory is made again");
std::fs::write(dir.join("git.lock"), "").expect("the lock file is made again");
}
drop(held);
hear.recv_timeout(std::time::Duration::from_secs(5))
.expect("the waiter gets a lock");
let (third_in, third_hears) = std::sync::mpsc::channel();
let third = std::thread::spawn(move || {
let _third = hold_clone(&clone).expect("third hold");
third_in.send(()).expect("report");
});
let waited = third_hears.recv_timeout(std::time::Duration::from_millis(300))
== Err(std::sync::mpsc::RecvTimeoutError::Timeout);
release.send(()).expect("let go");
third_hears
.recv_timeout(std::time::Duration::from_secs(5))
.expect("and then get it");
waiter.join().expect("thread");
third.join().expect("thread");
waited
}
#[test]
fn a_lock_file_removed_under_a_waiter_does_not_leave_it_holding_nothing() {
assert!(third_taker_waits_for_a_waiter_that_outlasted_a_removal(
false
));
}
#[test]
fn a_lock_file_made_again_under_a_waiter_is_not_the_one_it_holds() {
assert!(third_taker_waits_for_a_waiter_that_outlasted_a_removal(
true
));
}
#[test]
fn concurrent_fetches_into_one_clone_both_succeed() {
let home = tempfile::tempdir().expect("tempdir");
let origin = fixtures::checkout(&[("a", "a")]);
let remote = origin.path().to_str().expect("utf-8").to_owned();
let clone = home.path().join("git");
crate::git::init_bare(&clone).expect("bare");
for round in 0..5 {
fixtures::commit(origin.path(), &[(&format!("f{round}"), "x")], "more");
let fetches: Vec<_> = (0..2)
.map(|_| {
let (clone, remote) = (clone.clone(), remote.clone());
std::thread::spawn(move || {
crate::git::fetch(&clone, &remote, "main", fixtures::TEST_BUDGET)
})
})
.collect();
for fetch in fetches {
fetch.join().expect("thread").expect("both fetches succeed");
}
assert_eq!(
crate::git::remote_head(&clone, "main").expect("head"),
fixtures::head_of(origin.path())
);
}
}
#[test]
fn a_second_hold_on_the_same_tree_is_refused() {
let home = tempfile::tempdir().expect("tempdir");
let tree = Tree::for_sheep(home.path(), "web");
let first = hold(&tree).expect("the first hold");
let err = hold(&tree).expect_err("the second must be refused");
assert!(
matches!(&err, Error::AlreadyDeploying { sheep } if sheep == "web"),
"must name the sheep: {err:?}"
);
drop(first);
}
#[test]
fn a_dropped_hold_frees_the_tree() {
let home = tempfile::tempdir().expect("tempdir");
let tree = Tree::for_sheep(home.path(), "web");
drop(hold(&tree).expect("the first hold"));
hold(&tree).expect("the tree must be free once the first is dropped");
}
#[test]
fn a_lock_failure_that_is_not_contention_says_what_it_was() {
let path = std::path::PathBuf::from("/x/deploy.lock");
let contended = refusal("web", path.clone(), Errno::EWOULDBLOCK);
assert!(
matches!(&contended, Error::AlreadyDeploying { sheep } if sheep == "web"),
"contention is the one case that claim is true for: {contended:?}"
);
for errno in [Errno::ENOLCK, Errno::EIO, Errno::EOPNOTSUPP] {
let err = refusal("web", path.clone(), errno);
assert!(
matches!(&err, Error::Io { path: named, .. } if named == &path),
"{errno:?} must name the lock file, not a rival process: {err:?}"
);
assert!(
!format!("{err}").contains("already running"),
"{errno:?} must not claim contention: {err}"
);
}
}
#[test]
fn the_lock_file_is_owner_only() {
let home = tempfile::tempdir().expect("tempdir");
let tree = Tree::for_sheep(home.path(), "web");
let mode = |path: &std::path::Path| {
std::fs::metadata(path)
.expect("lock file")
.permissions()
.mode()
& 0o777
};
drop(hold(&tree).expect("creates the lock file"));
assert_eq!(mode(&tree.lock_file()), LOCK_MODE, "as created");
std::fs::set_permissions(tree.lock_file(), Permissions::from_mode(0o644))
.expect("loosen, as an older version left it");
drop(hold(&tree).expect("reopens the lock file"));
assert_eq!(mode(&tree.lock_file()), LOCK_MODE, "tightened on reopen");
}
#[test]
fn two_sheep_do_not_contend() {
let home = tempfile::tempdir().expect("tempdir");
let web = hold(&Tree::for_sheep(home.path(), "web")).expect("web");
let worker = hold(&Tree::for_sheep(home.path(), "worker")).expect("worker");
drop((web, worker));
}
}