embers-test-support 0.1.0

Shared integration-test harnesses and helpers for Embers crates.
use std::fs::{File, OpenOptions};
use std::io;
#[cfg(unix)]
use std::os::fd::AsRawFd;
#[cfg(not(unix))]
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};

#[cfg(not(unix))]
use std::thread;
#[cfg(not(unix))]
use std::time::{Duration, Instant};
use tokio::sync::{Mutex, OwnedMutexGuard};

const TEST_LOCK_FILE_NAME: &str = "embers-integration-tests.lock";
#[cfg(not(unix))]
const FILE_LOCK_RETRY_DELAY: Duration = Duration::from_millis(10);
#[cfg(not(unix))]
const FILE_LOCK_TIMEOUT: Duration = Duration::from_secs(10);

fn process_lock() -> Arc<Mutex<()>> {
    static LOCK: OnceLock<Arc<Mutex<()>>> = OnceLock::new();
    LOCK.get_or_init(|| Arc::new(Mutex::new(()))).clone()
}

pub struct InterprocessTestLock {
    _process_guard: OwnedMutexGuard<()>,
    file: File,
    #[cfg(not(unix))]
    path: PathBuf,
}

pub async fn acquire_test_lock() -> io::Result<InterprocessTestLock> {
    let process_guard = process_lock().lock_owned().await;
    let path = std::env::temp_dir().join(TEST_LOCK_FILE_NAME);

    #[cfg(unix)]
    let file = tokio::task::spawn_blocking(move || acquire_file_lock(path))
        .await
        .map_err(|error| io::Error::other(error.to_string()))??;

    #[cfg(not(unix))]
    let (file, path) = tokio::task::spawn_blocking(move || acquire_file_lock(path))
        .await
        .map_err(|error| io::Error::other(error.to_string()))??;

    Ok(InterprocessTestLock {
        _process_guard: process_guard,
        file,
        #[cfg(not(unix))]
        path,
    })
}

#[cfg(unix)]
fn acquire_file_lock(path: std::path::PathBuf) -> io::Result<File> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let file = OpenOptions::new()
        .read(true)
        .write(true)
        .create(true)
        .truncate(false)
        .open(path)?;
    let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
    if result != 0 {
        return Err(io::Error::last_os_error());
    }
    Ok(file)
}

#[cfg(not(unix))]
fn acquire_file_lock(path: PathBuf) -> io::Result<(File, PathBuf)> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let started = Instant::now();
    loop {
        match OpenOptions::new()
            .read(true)
            .write(true)
            .create_new(true)
            .open(&path)
        {
            Ok(file) => return Ok((file, path)),
            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
                thread::sleep(FILE_LOCK_RETRY_DELAY);
                if started.elapsed() >= FILE_LOCK_TIMEOUT {
                    return Err(io::Error::new(
                        io::ErrorKind::TimedOut,
                        format!(
                            "timed out acquiring integration test lock at {}; remove the orphaned lock file if no other test process is using it",
                            path.display()
                        ),
                    ));
                }
            }
            Err(error) => return Err(error),
        }
    }
}

impl Drop for InterprocessTestLock {
    fn drop(&mut self) {
        #[cfg(unix)]
        {
            let _ = unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) };
        }
        #[cfg(not(unix))]
        {
            let _ = std::fs::remove_file(&self.path);
        }
    }
}