use std::collections::BTreeMap;
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration;
use anyhow::{Result, bail};
use mj_core::targets::{
CommandExecutor, ImageHost, ProvisionStage, ProvisionStageGuard, TargetTemplate,
};
const POLL_INTERVAL: Duration = Duration::from_millis(250);
pub(crate) fn image_pull_mutex(host: &ImageHost, image: &str) -> Arc<Mutex<()>> {
static LOCKS: std::sync::OnceLock<Mutex<BTreeMap<String, std::sync::Weak<Mutex<()>>>>> =
std::sync::OnceLock::new();
let key = format!("{}|{image}", host.label());
let mut locks = LOCKS
.get_or_init(Mutex::default)
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
locks.retain(|_, lock| lock.strong_count() > 0);
let slot = locks.entry(key).or_default();
if let Some(lock) = slot.upgrade() {
return lock;
}
let lock = Arc::new(Mutex::new(()));
*slot = Arc::downgrade(&lock);
lock
}
pub(crate) fn hold_image_pull<'a>(
lock: &'a Mutex<()>,
is_cancelled: impl Fn() -> bool,
on_wait: impl FnOnce(),
) -> Result<MutexGuard<'a, ()>> {
let mut on_wait = Some(on_wait);
loop {
match lock.try_lock() {
Ok(guard) => return Ok(guard),
Err(std::sync::TryLockError::Poisoned(poisoned)) => return Ok(poisoned.into_inner()),
Err(std::sync::TryLockError::WouldBlock) => {
if let Some(on_wait) = on_wait.take() {
on_wait();
}
if is_cancelled() {
bail!("cancelled while waiting for image download");
}
std::thread::sleep(POLL_INTERVAL);
}
}
}
}
pub(crate) fn with_image_ready<T>(
target: &TargetTemplate,
executor: &impl CommandExecutor,
work: impl FnOnce() -> Result<T>,
) -> Result<T> {
let Some((host, container)) = target.image_host() else {
return work();
};
let image = container.image.clone();
let lock = image_pull_mutex(&host, &image);
let mut waiting = None;
let guard = hold_image_pull(
&lock,
|| executor.cancellation_requested(),
|| {
waiting = Some(ProvisionStageGuard::new(
executor,
ProvisionStage::PullingImage,
));
executor.notify_notice(&format!("Waiting for image {image} to finish downloading"));
},
)?;
drop(waiting);
let result = work();
drop(guard);
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
#[test]
fn a_create_waits_for_the_in_flight_pull_of_its_image() {
let lock = Arc::new(Mutex::new(()));
let released = Arc::new(AtomicBool::new(false));
let waits = Arc::new(AtomicUsize::new(0));
let downloader = {
let lock = lock.clone();
let released = released.clone();
std::thread::spawn(move || {
let guard = lock.lock().unwrap();
std::thread::sleep(Duration::from_millis(400));
released.store(true, Ordering::Release);
drop(guard);
})
};
while lock.try_lock().is_ok() {
std::thread::sleep(Duration::from_millis(10));
}
let guard = hold_image_pull(
&lock,
|| false,
|| {
waits.fetch_add(1, Ordering::Release);
},
)
.expect("the launch takes the lock once the download finishes");
assert!(
released.load(Ordering::Acquire),
"the launch proceeded while the download still held the lock"
);
assert_eq!(
waits.load(Ordering::Acquire),
1,
"the wait should be announced exactly once"
);
drop(guard);
downloader.join().expect("the download thread finishes");
}
#[test]
fn a_waiting_create_stops_when_cancelled() {
let lock = Mutex::new(());
let held = lock.lock().unwrap();
let error = hold_image_pull(&lock, || true, || {})
.expect_err("a cancelled wait must not return a lock it never took");
assert!(
format!("{error:#}").contains("cancelled while waiting for image download"),
"{error:#}"
);
drop(held);
}
#[test]
fn the_pull_lock_is_shared_per_host_and_image() {
let first = image_pull_mutex(&ImageHost::LocalPodman, "ghcr.io/example/dev:latest");
let again = image_pull_mutex(&ImageHost::LocalPodman, "ghcr.io/example/dev:latest");
assert!(Arc::ptr_eq(&first, &again));
let other_image = image_pull_mutex(&ImageHost::LocalPodman, "ghcr.io/example/other:latest");
assert!(!Arc::ptr_eq(&first, &other_image));
let other_host = image_pull_mutex(&ImageHost::LocalDocker, "ghcr.io/example/dev:latest");
assert!(!Arc::ptr_eq(&first, &other_host));
}
}