use anyhow::{Context, Result};
use std::path::PathBuf;
fn home() -> Result<PathBuf> {
std::env::var_os("HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.context("HOME is not set; qex cannot locate its config or state directory")
}
fn xdg(var: &str, default_suffix: &str) -> Result<PathBuf> {
match std::env::var_os(var) {
Some(v) if !v.is_empty() => Ok(PathBuf::from(v)),
_ => Ok(home()?.join(default_suffix)),
}
}
pub fn config_file() -> Result<PathBuf> {
Ok(xdg("XDG_CONFIG_HOME", ".config")?.join("qex.toml"))
}
pub fn state_dir() -> Result<PathBuf> {
Ok(xdg("XDG_STATE_HOME", ".local/state")?.join("qex"))
}
pub fn jobs_dir() -> Result<PathBuf> {
Ok(state_dir()?.join("jobs"))
}
pub fn runtime_dir() -> Result<PathBuf> {
Ok(state_dir()?.join("run"))
}
const MAX_SOCKET_PATH: usize = 100;
pub fn socket_path() -> Result<PathBuf> {
let preferred = runtime_dir()?.join("s");
if preferred.as_os_str().len() <= MAX_SOCKET_PATH {
return Ok(preferred);
}
Ok(short_socket_dir(&preferred)?.join("s"))
}
fn short_socket_dir(preferred: &std::path::Path) -> Result<PathBuf> {
use std::os::unix::fs::MetadataExt;
let uid = unsafe { libc::getuid() };
let dir = std::env::temp_dir().join(format!("qex-{uid}-{}", path_hash(preferred)));
match std::fs::symlink_metadata(&dir) {
Ok(meta) => {
if !meta.is_dir() {
anyhow::bail!(
"qex needs the directory {} for its socket, but that path is a file",
dir.display()
);
}
if meta.uid() != uid {
anyhow::bail!(
"the directory {} belongs to the user {}, and qex will not use it",
dir.display(),
meta.uid()
);
}
ensure_dir(&dir, 0o700)?;
}
Err(_) => ensure_dir(&dir, 0o700)?,
}
let pid = dir.join(PID_FILE);
if !pid.exists() {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.mode(0o600)
.open(&pid)
.ok();
}
Ok(dir)
}
const SOCKET_ANSWER_LIMIT: std::time::Duration = std::time::Duration::from_millis(100);
pub const PID_FILE: &str = "pid";
const SWEEP_LIMIT: std::time::Duration = std::time::Duration::from_secs(3);
pub fn reap_stale_socket_dirs() {
let mut own: Vec<PathBuf> = Vec::new();
if let Ok(dir) = runtime_dir() {
own.push(dir);
}
if let Ok(socket) = socket_path() {
if let Some(dir) = socket.parent() {
own.push(dir.to_path_buf());
}
}
sweep_socket_dirs(&std::env::temp_dir(), &own, SWEEP_LIMIT);
}
fn sweep_socket_dirs(dir: &std::path::Path, own: &[PathBuf], limit: std::time::Duration) {
use std::os::unix::fs::MetadataExt;
let start = std::time::Instant::now();
let uid = unsafe { libc::getuid() };
let prefix = format!("qex-{uid}-");
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if start.elapsed() >= limit {
return;
}
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.starts_with(&prefix) {
continue;
}
if own.iter().any(|p| *p == entry.path()) {
continue;
}
let Ok(meta) = std::fs::symlink_metadata(entry.path()) else {
continue;
};
if !meta.is_dir() || meta.uid() != uid {
continue;
}
let Some(claim) = claim_unused_dir(&entry.path(), SOCKET_ANSWER_LIMIT) else {
continue;
};
std::fs::remove_dir_all(entry.path()).ok();
drop(claim);
}
}
fn claim_unused_dir(dir: &std::path::Path, limit: std::time::Duration) -> Option<Claim> {
let file = match std::fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(dir.join(PID_FILE))
{
Ok(file) => match try_lock(&file) {
LockTry::Taken => Some(file),
LockTry::Held | LockTry::Failed(_) => return None,
},
Err(_) => return None,
};
if !matches!(
ask_socket(&dir.join("s"), limit),
SocketAnswer::NobodyListens
) {
return None;
}
Some(Claim { _file: file })
}
struct Claim {
_file: Option<std::fs::File>,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum SocketAnswer {
Answers,
NobodyListens,
Unknown,
}
#[cfg(test)]
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum PidFile {
Held,
Free,
Unknown,
}
const PID_LOCK_ATTEMPTS: u32 = 5;
pub const PID_LOCK_PATIENCE: std::time::Duration = std::time::Duration::from_secs(1);
pub struct PidFileLock {
#[allow(dead_code)]
file: std::fs::File,
}
#[cfg(test)]
impl PidFileLock {
fn nlink(&self) -> u64 {
use std::os::unix::fs::MetadataExt;
self.file.metadata().map(|m| m.nlink()).unwrap_or(0)
}
fn ino(&self) -> u64 {
use std::os::unix::fs::MetadataExt;
self.file.metadata().map(|m| m.ino()).unwrap_or(0)
}
}
pub enum PidHold {
Held(PidFileLock),
Busy,
Unusable(String),
}
enum LockTry {
Taken,
Held,
Failed(std::io::Error),
}
fn try_lock(file: &std::fs::File) -> LockTry {
use std::os::unix::io::AsRawFd;
for _ in 0..5 {
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
return LockTry::Taken;
}
let e = std::io::Error::last_os_error();
match e.kind() {
std::io::ErrorKind::WouldBlock => return LockTry::Held,
std::io::ErrorKind::Interrupted => continue,
_ => return LockTry::Failed(e),
}
}
LockTry::Failed(std::io::Error::from(std::io::ErrorKind::Interrupted))
}
pub fn hold_pid_file(socket: &std::path::Path, patience: std::time::Duration) -> PidHold {
use std::io::Write as _;
let Some(dir) = socket.parent() else {
return PidHold::Unusable(format!(
"the socket path {} has no directory",
socket.display()
));
};
let path = dir.join(PID_FILE);
let deadline = std::time::Instant::now() + patience;
for _ in 0..PID_LOCK_ATTEMPTS {
let mut file = match std::fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)
{
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return PidHold::Unusable(format!(
"qex cannot make the file {}: {e}. Start qex again.",
path.display()
))
}
Err(e) => {
return PidHold::Unusable(format!(
"qex cannot open the file {}: {e}. Delete that file if no coordinator \
operates.",
path.display()
))
}
};
loop {
match try_lock(&file) {
LockTry::Taken => break,
LockTry::Held => {
if std::time::Instant::now() >= deadline {
return PidHold::Busy;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
LockTry::Failed(e) => {
return PidHold::Unusable(format!(
"qex cannot lock the file {}: {e}. Delete that file if no \
coordinator operates.",
path.display(),
))
}
}
}
let gone = matches!(file.metadata(), Ok(meta) if
std::os::unix::fs::MetadataExt::nlink(&meta) == 0);
if gone {
continue;
}
if let Err(e) = file.set_len(0).and_then(|()| {
file.write_all(std::process::id().to_string().as_bytes())?;
file.flush()
}) {
return PidHold::Unusable(format!(
"qex cannot write the file {}: {e}. Delete that file if no coordinator \
operates.",
path.display()
));
}
return PidHold::Held(PidFileLock { file });
}
PidHold::Unusable(format!(
"a sweep deleted the file {} while qex took the lock on it. Start qex again.",
path.display()
))
}
#[cfg(test)]
pub fn pid_file_state(dir: &std::path::Path) -> PidFile {
use std::os::unix::io::AsRawFd;
let file = match std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(dir.join(PID_FILE))
{
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return PidFile::Free,
Err(_) => return PidFile::Unknown,
};
match try_lock(&file) {
LockTry::Taken => {
unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) };
PidFile::Free
}
LockTry::Held => PidFile::Held,
LockTry::Failed(_) => PidFile::Unknown,
}
}
pub fn ask_socket(socket: &std::path::Path, limit: std::time::Duration) -> SocketAnswer {
if std::fs::symlink_metadata(socket).is_err() {
return SocketAnswer::NobodyListens;
}
let Some(address) = unix_address(socket) else {
return SocketAnswer::Unknown;
};
let deadline = std::time::Instant::now() + limit;
loop {
match connect_once(&address, deadline) {
Some(answer) => return answer,
None => {
if std::time::Instant::now() >= deadline {
return SocketAnswer::Unknown;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
}
}
fn unix_address(path: &std::path::Path) -> Option<libc::sockaddr_un> {
use std::os::unix::ffi::OsStrExt;
let bytes = path.as_os_str().as_bytes();
let mut address: libc::sockaddr_un = unsafe { std::mem::zeroed() };
if bytes.len() >= address.sun_path.len() {
return None;
}
address.sun_family = libc::AF_UNIX as libc::sa_family_t;
for (slot, byte) in address.sun_path.iter_mut().zip(bytes) {
*slot = *byte as libc::c_char;
}
Some(address)
}
fn answer_of(error: Option<i32>) -> SocketAnswer {
match error {
Some(libc::ECONNREFUSED) | Some(libc::ENOENT) | Some(libc::ENOTSOCK) => {
SocketAnswer::NobodyListens
}
_ => SocketAnswer::Unknown,
}
}
fn finish_connect(fd: libc::c_int, deadline: std::time::Instant) -> Option<SocketAnswer> {
let left = deadline.saturating_duration_since(std::time::Instant::now());
let mut waiting = libc::pollfd {
fd,
events: libc::POLLOUT,
revents: 0,
};
let ready = unsafe {
libc::poll(
&mut waiting,
1,
left.as_millis().min(i32::MAX as u128) as i32,
)
};
if ready == 0 {
return Some(SocketAnswer::Unknown);
}
if ready < 0 {
return match std::io::Error::last_os_error().raw_os_error() {
Some(libc::EINTR) => None,
other => Some(answer_of(other)),
};
}
let mut error: libc::c_int = 0;
let mut size = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
let read = unsafe {
libc::getsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_ERROR,
&mut error as *mut libc::c_int as *mut libc::c_void,
&mut size,
)
};
if read != 0 {
return Some(SocketAnswer::Unknown);
}
if error == 0 {
return Some(SocketAnswer::Answers);
}
if error == libc::EAGAIN || error == libc::EINTR {
return None;
}
Some(answer_of(Some(error)))
}
fn connect_once(address: &libc::sockaddr_un, deadline: std::time::Instant) -> Option<SocketAnswer> {
unsafe {
let fd = libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0);
if fd < 0 {
return Some(SocketAnswer::Unknown);
}
let flags = libc::fcntl(fd, libc::F_GETFL);
if flags < 0 || libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
libc::close(fd);
return Some(SocketAnswer::Unknown);
}
let result = libc::connect(
fd,
address as *const libc::sockaddr_un as *const libc::sockaddr,
std::mem::size_of::<libc::sockaddr_un>() as libc::socklen_t,
);
if result == 0 {
libc::close(fd);
return Some(SocketAnswer::Answers);
}
let error = std::io::Error::last_os_error().raw_os_error();
let answer = match error {
Some(libc::EINPROGRESS) => finish_connect(fd, deadline),
Some(libc::EAGAIN) | Some(libc::EINTR) => None,
other => Some(answer_of(other)),
};
libc::close(fd);
answer
}
}
fn path_hash(path: &std::path::Path) -> String {
use std::os::unix::ffi::OsStrExt;
let mut hash: u64 = 0xcbf29ce484222325;
for byte in path.as_os_str().as_bytes() {
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{hash:08x}")
}
pub fn program_path() -> Result<PathBuf> {
let exe = std::env::current_exe().context("finding the qex program file")?;
if exe.exists() {
return Ok(exe);
}
let text = exe.to_string_lossy();
if let Some(stripped) = text.strip_suffix(" (deleted)") {
let path = PathBuf::from(stripped);
if path.exists() {
return Ok(path);
}
anyhow::bail!(
"the qex program file {} no longer exists. Something replaced or deleted it \
while the coordinator was operating. Stop the coordinator and start it again: \
`qex info --no-start --json` gives its process id.",
stripped
);
}
anyhow::bail!(
"the qex program file {} no longer exists. Stop the coordinator and start it again.",
exe.display()
)
}
pub fn program_file_changed() -> bool {
match std::env::current_exe() {
Ok(exe) => !exe.exists(),
Err(_) => false,
}
}
pub fn spawn_lock_path() -> Result<PathBuf> {
Ok(runtime_dir()?.join("spawn.lock"))
}
pub fn daemon_log_path() -> Result<PathBuf> {
Ok(runtime_dir()?.join("daemon.log"))
}
pub fn job_dir(id: &uuid::Uuid) -> Result<PathBuf> {
Ok(jobs_dir()?.join(id.to_string()))
}
pub fn ensure_dir(path: &std::path::Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
if !path.exists() {
std::fs::create_dir_all(path)
.with_context(|| format!("creating directory {}", path.display()))?;
}
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
.with_context(|| format!("setting mode {mode:o} on {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::{env_lock, EnvVar};
#[test]
fn xdg_overrides_are_honoured() {
let _guard = env_lock();
let _c = EnvVar::set("XDG_CONFIG_HOME", "/tmp/qex-test-cfg");
let _s = EnvVar::set("XDG_STATE_HOME", "/tmp/qex-test-state");
assert_eq!(
config_file().unwrap(),
PathBuf::from("/tmp/qex-test-cfg/qex.toml")
);
assert_eq!(
jobs_dir().unwrap(),
PathBuf::from("/tmp/qex-test-state/qex/jobs")
);
}
#[test]
fn defaults_fall_back_to_home() {
let _guard = env_lock();
let _h = EnvVar::set("HOME", "/home/example");
let _c = EnvVar::unset("XDG_CONFIG_HOME");
let _s = EnvVar::unset("XDG_STATE_HOME");
let _r = EnvVar::unset("XDG_RUNTIME_DIR");
assert_eq!(
config_file().unwrap(),
PathBuf::from("/home/example/.config/qex.toml")
);
assert_eq!(
state_dir().unwrap(),
PathBuf::from("/home/example/.local/state/qex")
);
assert_eq!(
runtime_dir().unwrap(),
PathBuf::from("/home/example/.local/state/qex/run")
);
}
#[test]
fn socket_path_stays_within_sun_path_limits() {
let _guard = env_lock();
let _s = EnvVar::set("XDG_STATE_HOME", "/tmp/qex-sock-test");
let p = socket_path().unwrap();
assert_eq!(p, PathBuf::from("/tmp/qex-sock-test/qex/run/s"));
assert!(p.as_os_str().len() <= MAX_SOCKET_PATH);
}
#[test]
fn the_socket_does_not_depend_on_the_runtime_variable() {
let _guard = env_lock();
let _s = EnvVar::set("XDG_STATE_HOME", "/tmp/qex-one-state");
let with_variable = {
let _r = EnvVar::set("XDG_RUNTIME_DIR", "/run/user/1000");
socket_path().unwrap()
};
let without_variable = {
let _r = EnvVar::unset("XDG_RUNTIME_DIR");
socket_path().unwrap()
};
assert_eq!(
with_variable, without_variable,
"one state directory must give one socket, and thus one coordinator"
);
}
#[test]
fn a_long_state_directory_gives_a_short_socket_path() {
let _guard = env_lock();
let own_tmp = TestDir::make("qex-shortdirtest");
let _t = EnvVar::set("TMPDIR", own_tmp.path().to_str().unwrap());
let long = format!("/tmp/{}", "very-long-directory-name/".repeat(8));
let _s = EnvVar::set("XDG_STATE_HOME", &long);
let p = socket_path().unwrap();
assert!(
p.as_os_str().len() <= MAX_SOCKET_PATH,
"the socket path {} is still too long",
p.display()
);
assert_eq!(p, socket_path().unwrap());
assert!(
p.parent().unwrap().join(PID_FILE).exists(),
"the short socket directory must hold a pid file from the moment it exists"
);
let _s2 = EnvVar::set("XDG_STATE_HOME", &format!("{long}other/"));
assert_ne!(p, socket_path().unwrap());
}
fn test_root() -> PathBuf {
PathBuf::from("/tmp")
}
struct TestDir(PathBuf);
impl TestDir {
fn make(name: &str) -> Self {
let path = test_root().join(format!("{name}-{}", std::process::id()));
std::fs::remove_dir_all(&path).ok();
std::fs::create_dir_all(&path).unwrap();
TestDir(path)
}
fn path(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).ok();
}
}
struct OpenSockets(Vec<libc::c_int>);
impl Drop for OpenSockets {
fn drop(&mut self) {
for fd in self.0.drain(..) {
unsafe { libc::close(fd) };
}
}
}
fn a_socket_that_never_answers(path: &std::path::Path) -> Option<OpenSockets> {
let address = unix_address(path).expect("the path of the test socket is short");
let size = std::mem::size_of::<libc::sockaddr_un>() as libc::socklen_t;
let mut open = Vec::new();
let can_wait = unsafe {
let listener = libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0);
assert!(listener >= 0, "the test cannot open a socket");
open.push(listener);
let bound = libc::bind(
listener,
&address as *const libc::sockaddr_un as *const libc::sockaddr,
size,
);
assert_eq!(bound, 0, "the test cannot bind {}", path.display());
assert_eq!(libc::listen(listener, 1), 0, "the test cannot listen");
let mut full = false;
for _ in 0..256 {
let client = libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0);
assert!(client >= 0, "the test cannot open a socket");
let flags = libc::fcntl(client, libc::F_GETFL);
libc::fcntl(client, libc::F_SETFL, flags | libc::O_NONBLOCK);
let result = libc::connect(
client,
&address as *const libc::sockaddr_un as *const libc::sockaddr,
size,
);
if result != 0 {
let error = std::io::Error::last_os_error().raw_os_error();
libc::close(client);
full = matches!(error, Some(libc::EAGAIN) | Some(libc::EINPROGRESS));
break;
}
open.push(client);
}
full
};
let sockets = OpenSockets(open);
if !can_wait {
return None;
}
Some(sockets)
}
fn a_socket_file_with_no_owner(path: &std::path::Path) {
let address = unix_address(path).expect("the path of the test socket is short");
let size = std::mem::size_of::<libc::sockaddr_un>() as libc::socklen_t;
unsafe {
let fd = libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0);
assert!(fd >= 0, "the test cannot open a socket");
let bound = libc::bind(
fd,
&address as *const libc::sockaddr_un as *const libc::sockaddr,
size,
);
assert_eq!(bound, 0, "the test cannot bind {}", path.display());
assert_eq!(libc::listen(fd, 1), 0, "the test cannot listen");
libc::close(fd);
}
assert!(path.exists(), "the bind must leave the socket file");
}
fn a_held_pid_file(dir: &std::path::Path) -> std::fs::File {
use std::io::Write as _;
use std::os::unix::io::AsRawFd;
let mut file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(dir.join(PID_FILE))
.unwrap();
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
assert_eq!(rc, 0, "the test cannot lock the pid file");
file.write_all(std::process::id().to_string().as_bytes())
.unwrap();
file.flush().unwrap();
file
}
#[test]
fn the_sweep_keeps_the_directory_of_a_coordinator_that_operates() {
let uid = unsafe { libc::getuid() };
let base = TestDir::make("qex-reaptest");
let refuses_but_live = base.path().join(format!("qex-{uid}-livepid"));
let own = base.path().join(format!("qex-{uid}-own"));
std::fs::create_dir_all(&refuses_but_live).unwrap();
std::fs::create_dir_all(&own).unwrap();
a_socket_file_with_no_owner(&refuses_but_live.join("s"));
let _held_live = a_held_pid_file(&refuses_but_live);
let busy_with_pid = base.path().join(format!("qex-{uid}-busypid"));
let busy_no_pid = base.path().join(format!("qex-{uid}-busy"));
let busy = (|| {
std::fs::create_dir_all(&busy_with_pid).unwrap();
std::fs::create_dir_all(&busy_no_pid).unwrap();
let one = a_socket_that_never_answers(&busy_with_pid.join("s"))?;
let two = a_socket_that_never_answers(&busy_no_pid.join("s"))?;
let held = a_held_pid_file(&busy_with_pid);
Some((one, two, held))
})();
if busy.is_none() {
eprintln!(
"this system refuses a connection when the queue of a socket is full, so \
the two directories with a socket that gives no answer do not take part"
);
std::fs::remove_dir_all(&busy_with_pid).ok();
std::fs::remove_dir_all(&busy_no_pid).ok();
}
let (sender, receiver) = std::sync::mpsc::channel();
let directory = base.path().to_path_buf();
let keep = own.clone();
std::thread::spawn(move || {
sweep_socket_dirs(&directory, &[keep], std::time::Duration::from_secs(3));
sender.send(()).ok();
});
let finished = receiver
.recv_timeout(std::time::Duration::from_secs(10))
.is_ok();
assert!(
finished,
"the sweep waits for a socket that never answers; every qex command then waits"
);
if busy.is_some() {
assert!(
busy_with_pid.exists(),
"the sweep deleted the socket directory of a coordinator that operates \
and that holds the lock on its pid file"
);
assert!(
busy_no_pid.exists(),
"the sweep deleted the socket directory of a coordinator that operates; \
a socket that gives no answer is not a socket that nobody holds"
);
}
assert!(
refuses_but_live.exists(),
"the sweep deleted the directory of a process that operates; the answer of a \
socket cannot overrule a process that is alive"
);
assert!(
own.exists(),
"the sweep must keep the directory of this process"
);
}
#[test]
fn the_sweep_deletes_the_directory_of_a_coordinator_that_stopped() {
let uid = unsafe { libc::getuid() };
let base = TestDir::make("qex-deadtest");
let no_socket = base.path().join(format!("qex-{uid}-nosocket"));
let dead_socket = base.path().join(format!("qex-{uid}-dead"));
let dead_pid = base.path().join(format!("qex-{uid}-deadpid"));
for dir in [&no_socket, &dead_socket, &dead_pid] {
std::fs::create_dir_all(dir).unwrap();
}
a_socket_file_with_no_owner(&dead_socket.join("s"));
a_socket_file_with_no_owner(&dead_pid.join("s"));
std::fs::write(dead_pid.join(PID_FILE), std::process::id().to_string()).unwrap();
sweep_socket_dirs(base.path(), &[], std::time::Duration::from_secs(3));
assert!(
!no_socket.exists(),
"the sweep must delete a directory with no socket"
);
assert!(
!dead_socket.exists(),
"the sweep must delete a directory with a socket that refuses a connection"
);
assert!(
!dead_pid.exists(),
"the sweep must delete a directory that holds a number and no lock; a number \
is not evidence that a coordinator operates"
);
}
#[test]
fn each_sweep_continues_the_work_that_the_limit_stopped() {
let uid = unsafe { libc::getuid() };
let base = TestDir::make("qex-limittest");
let count = 200;
for n in 0..count {
std::fs::create_dir_all(base.path().join(format!("qex-{uid}-{n}"))).unwrap();
}
let left = || std::fs::read_dir(base.path()).unwrap().count();
sweep_socket_dirs(base.path(), &[], std::time::Duration::ZERO);
assert_eq!(
left(),
count,
"a sweep with no time must delete no directory"
);
let mut sweeps = 0;
while left() > 0 {
sweeps += 1;
assert!(
sweeps <= 10_000,
"the sweeps make no progress; {} directories stay",
left()
);
sweep_socket_dirs(base.path(), &[], std::time::Duration::from_micros(500));
}
for n in 0..count {
std::fs::create_dir_all(base.path().join(format!("qex-{uid}-{n}"))).unwrap();
}
sweep_socket_dirs(base.path(), &[], std::time::Duration::from_secs(30));
assert_eq!(left(), 0, "a sweep with time must finish the work");
}
#[test]
fn a_socket_that_never_answers_gives_the_unknown_answer() {
let base = TestDir::make("qex-answertest");
let stuck = base.path().join("s");
let Some(_sockets) = a_socket_that_never_answers(&stuck) else {
eprintln!(
"this test did not run: this system gives a refusal, and not a wait, for \
a socket with a full queue"
);
return;
};
let (sender, receiver) = std::sync::mpsc::channel();
let path = stuck.clone();
std::thread::spawn(move || {
sender
.send(ask_socket(&path, std::time::Duration::from_millis(50)))
.ok();
});
let answer = receiver.recv_timeout(std::time::Duration::from_secs(10));
assert_eq!(
answer.ok(),
Some(SocketAnswer::Unknown),
"a socket that does not answer inside the limit can belong to a coordinator \
that is busy, and the question must stop at that limit"
);
}
#[test]
fn a_connect_that_finishes_later_gives_the_true_answer() {
let port: u16 = 1;
let mut address: libc::sockaddr_in = unsafe { std::mem::zeroed() };
address.sin_family = libc::AF_INET as libc::sa_family_t;
address.sin_port = port.to_be();
address.sin_addr.s_addr = u32::from_ne_bytes([127, 0, 0, 1]);
unsafe {
let fd = libc::socket(libc::AF_INET, libc::SOCK_STREAM, 0);
assert!(fd >= 0, "the test cannot open a socket");
let flags = libc::fcntl(fd, libc::F_GETFL);
libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
let result = libc::connect(
fd,
&address as *const libc::sockaddr_in as *const libc::sockaddr,
std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
);
let error = std::io::Error::last_os_error().raw_os_error();
if result == 0 || error != Some(libc::EINPROGRESS) {
libc::close(fd);
eprintln!(
"this test did not run: this system finished the connect at once, so \
it makes no `EINPROGRESS` to measure"
);
return;
}
let answer = finish_connect(
fd,
std::time::Instant::now() + std::time::Duration::from_secs(10),
);
libc::close(fd);
if answer == Some(SocketAnswer::Answers) {
eprintln!(
"this test did not run: a program listens at the port {port}, so this \
test cannot measure a refusal"
);
return;
}
assert_eq!(
answer,
Some(SocketAnswer::NobodyListens),
"the answer of a connect that the system finishes later arrives in \
`SO_ERROR`, and nobody listens at this port"
);
}
}
#[test]
fn a_socket_with_no_process_says_that_nobody_listens() {
let base = TestDir::make("qex-refusetest");
let missing = base.path().join("s");
assert_eq!(
ask_socket(&missing, std::time::Duration::from_millis(50)),
SocketAnswer::NobodyListens,
"a socket file that is not there holds no coordinator"
);
a_socket_file_with_no_owner(&missing);
assert_eq!(
ask_socket(&missing, std::time::Duration::from_millis(50)),
SocketAnswer::NobodyListens,
"a socket file that no process holds refuses a connection"
);
let plain = base.path().join("plain");
std::fs::write(&plain, b"").unwrap();
assert_eq!(
ask_socket(&plain, std::time::Duration::from_millis(50)),
SocketAnswer::NobodyListens,
"a path that is not a socket holds no coordinator"
);
}
fn wait_for_pid_file(dir: &std::path::Path, want: PidFile) -> PidFile {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let state = pid_file_state(dir);
if state == want || std::time::Instant::now() >= deadline {
return state;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
#[test]
fn a_number_in_the_pid_file_is_not_evidence_of_a_coordinator() {
let base = TestDir::make("qex-pidtest");
assert_eq!(
pid_file_state(base.path()),
PidFile::Free,
"a directory with no pid file holds no coordinator"
);
std::fs::write(base.path().join(PID_FILE), std::process::id().to_string()).unwrap();
assert_eq!(
pid_file_state(base.path()),
PidFile::Free,
"a number in a file is not evidence of a coordinator; only the lock is"
);
let held = a_held_pid_file(base.path());
assert_eq!(
pid_file_state(base.path()),
PidFile::Held,
"a process holds the lock, so a coordinator operates"
);
drop(held);
}
#[test]
fn a_try_that_fails_leaves_the_file_of_the_owner_as_it_was() {
let base = TestDir::make("qex-truncatetest");
let socket = base.path().join("s");
let path = base.path().join(PID_FILE);
std::fs::write(&path, b"12345").unwrap();
let held = a_held_pid_file(base.path());
let answer = hold_pid_file(&socket, std::time::Duration::from_millis(50));
assert!(
matches!(answer, PidHold::Busy),
"a lock that a different process holds must give Busy"
);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
std::process::id().to_string(),
"the try emptied the file of the process that holds the lock"
);
drop(held);
}
#[test]
fn the_lock_of_a_coordinator_is_on_a_file_that_a_name_points_to() {
let base = TestDir::make("qex-nlinktest");
let socket = base.path().join("s");
let path = base.path().join(PID_FILE);
std::fs::write(&path, b"").unwrap();
let dir = base.path().to_path_buf();
let (ready, holds_the_lock) = std::sync::mpsc::channel();
let sweep = std::thread::spawn(move || {
let file = a_held_pid_file(&dir);
ready.send(()).ok();
std::thread::sleep(std::time::Duration::from_millis(100));
std::fs::remove_file(dir.join(PID_FILE)).ok();
drop(file);
});
holds_the_lock
.recv_timeout(std::time::Duration::from_secs(30))
.expect("the thread of the test did not take the lock");
let answer = hold_pid_file(&socket, std::time::Duration::from_secs(5));
sweep.join().unwrap();
let PidHold::Held(lock) = answer else {
panic!("the coordinator must take the lock after the sweep gives it back");
};
assert!(
lock.nlink() >= 1,
"the coordinator holds its lock on a file that no name points to"
);
assert_eq!(
lock.ino(),
std::os::unix::fs::MetadataExt::ino(&std::fs::metadata(&path).unwrap()),
"the coordinator must hold the lock on the file that the path names now"
);
}
#[test]
fn a_file_that_is_not_there_does_not_ask_the_reader_to_delete_it() {
let base = TestDir::make("qex-remedytest");
let socket = base.path().join("no-such-directory").join("s");
let answer = hold_pid_file(&socket, std::time::Duration::from_millis(50));
let PidHold::Unusable(text) = answer else {
panic!("a directory that is not there must give Unusable");
};
assert!(
!text.contains("Delete that file"),
"the message asks the reader to delete a file that is not there: {text}"
);
assert!(
text.contains("Start qex again"),
"the message must give a step that the reader can take: {text}"
);
}
#[test]
fn a_coordinator_waits_through_the_moment_that_a_probe_holds_the_lock() {
let base = TestDir::make("qex-patiencetest");
let socket = base.path().join("s");
std::fs::write(base.path().join(PID_FILE), b"").unwrap();
let dir = base.path().to_path_buf();
let (ready, holds_the_lock) = std::sync::mpsc::channel();
let probe = std::thread::spawn(move || {
let file = a_held_pid_file(&dir);
ready.send(()).ok();
std::thread::sleep(std::time::Duration::from_millis(200));
drop(file);
});
holds_the_lock
.recv_timeout(std::time::Duration::from_secs(30))
.expect("the thread of the test did not take the lock");
let answer = hold_pid_file(&socket, std::time::Duration::from_secs(5));
probe.join().unwrap();
assert!(
matches!(answer, PidHold::Held(_)),
"a moment of a probe must not read as a coordinator"
);
}
#[test]
fn the_sweep_holds_the_lock_while_it_deletes() {
let base = TestDir::make("qex-claimtest");
std::fs::write(base.path().join(PID_FILE), b"").unwrap();
let claim = claim_unused_dir(base.path(), std::time::Duration::from_millis(50))
.expect("a directory with a free lock and no socket must give a claim");
assert_eq!(
pid_file_state(base.path()),
PidFile::Held,
"the claim must hold the lock while the caller deletes the directory"
);
drop(claim);
assert_eq!(
pid_file_state(base.path()),
PidFile::Free,
"the claim must give the lock back when it goes out of scope"
);
let empty = TestDir::make("qex-claimempty");
let claim = claim_unused_dir(empty.path(), std::time::Duration::from_millis(50))
.expect("a directory with no pid file must give a claim");
assert_eq!(
pid_file_state(empty.path()),
PidFile::Held,
"the claim must make the pid file and hold its lock, so that a coordinator \
that starts here meets the same lock"
);
drop(claim);
}
#[test]
fn a_kill_of_the_process_gives_the_lock_back() {
let base = TestDir::make("qex-killtest");
let path = base.path().join(PID_FILE);
std::fs::write(&path, b"").unwrap();
let name = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
let child = unsafe { libc::fork() };
assert!(child >= 0, "the test cannot make a process");
if child == 0 {
unsafe {
let fd = libc::open(name.as_ptr(), libc::O_RDWR);
if fd < 0 || libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) != 0 {
libc::_exit(1);
}
loop {
libc::pause();
}
}
}
let held = wait_for_pid_file(base.path(), PidFile::Held);
if held != PidFile::Held {
let mut status = 0;
let gone = unsafe { libc::waitpid(child, &mut status, libc::WNOHANG) };
if gone != 0 {
eprintln!(
"this test did not run: the child could not take the lock, and the \
state of qex is not known from it"
);
return;
}
unsafe { libc::kill(child, libc::SIGKILL) };
unsafe { libc::waitpid(child, &mut status, 0) };
panic!("the child holds the lock, so the file must say that a process operates");
}
unsafe {
libc::kill(child, libc::SIGKILL);
let mut status = 0;
libc::waitpid(child, &mut status, 0);
}
assert_eq!(
wait_for_pid_file(base.path(), PidFile::Free),
PidFile::Free,
"the kernel must give the lock back when the process stops, and a kill that \
the process cannot catch is the test of that promise"
);
}
#[test]
fn ensure_dir_applies_mode_regardless_of_umask() {
use std::os::unix::fs::PermissionsExt;
let dir = test_root().join(format!("qex-mode-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
ensure_dir(&dir, 0o700).unwrap();
let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o700,
"the group and other users must not read job directories"
);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
ensure_dir(&dir, 0o700).unwrap();
let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700);
std::fs::remove_dir_all(&dir).ok();
}
}