mj_controller/
image_pull_gate.rs1use std::collections::BTreeMap;
14use std::sync::{Arc, Mutex, MutexGuard};
15use std::time::Duration;
16
17use anyhow::{Result, bail};
18
19use mj_core::targets::{
20 CommandExecutor, ImageHost, ProvisionStage, ProvisionStageGuard, TargetTemplate,
21};
22
23const POLL_INTERVAL: Duration = Duration::from_millis(250);
30
31pub(crate) fn image_pull_mutex(host: &ImageHost, image: &str) -> Arc<Mutex<()>> {
37 static LOCKS: std::sync::OnceLock<Mutex<BTreeMap<String, std::sync::Weak<Mutex<()>>>>> =
38 std::sync::OnceLock::new();
39 let key = format!("{}|{image}", host.label());
40 let mut locks = LOCKS
41 .get_or_init(Mutex::default)
42 .lock()
43 .unwrap_or_else(std::sync::PoisonError::into_inner);
44 locks.retain(|_, lock| lock.strong_count() > 0);
45 let slot = locks.entry(key).or_default();
46 if let Some(lock) = slot.upgrade() {
47 return lock;
48 }
49 let lock = Arc::new(Mutex::new(()));
50 *slot = Arc::downgrade(&lock);
51 lock
52}
53
54pub(crate) fn hold_image_pull<'a>(
61 lock: &'a Mutex<()>,
62 is_cancelled: impl Fn() -> bool,
63 on_wait: impl FnOnce(),
64) -> Result<MutexGuard<'a, ()>> {
65 let mut on_wait = Some(on_wait);
66 loop {
67 match lock.try_lock() {
68 Ok(guard) => return Ok(guard),
69 Err(std::sync::TryLockError::Poisoned(poisoned)) => return Ok(poisoned.into_inner()),
72 Err(std::sync::TryLockError::WouldBlock) => {
73 if let Some(on_wait) = on_wait.take() {
74 on_wait();
75 }
76 if is_cancelled() {
77 bail!("cancelled while waiting for image download");
78 }
79 std::thread::sleep(POLL_INTERVAL);
80 }
81 }
82 }
83}
84
85pub(crate) fn with_image_ready<T>(
93 target: &TargetTemplate,
94 executor: &impl CommandExecutor,
95 work: impl FnOnce() -> Result<T>,
96) -> Result<T> {
97 let Some((host, container)) = target.image_host() else {
98 return work();
99 };
100 let image = container.image.clone();
101 let lock = image_pull_mutex(&host, &image);
102 let mut waiting = None;
106 let guard = hold_image_pull(
107 &lock,
108 || executor.cancellation_requested(),
109 || {
110 waiting = Some(ProvisionStageGuard::new(
111 executor,
112 ProvisionStage::PullingImage,
113 ));
114 executor.notify_notice(&format!("Waiting for image {image} to finish downloading"));
115 },
116 )?;
117 drop(waiting);
118 let result = work();
119 drop(guard);
120 result
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
127
128 #[test]
131 fn a_create_waits_for_the_in_flight_pull_of_its_image() {
132 let lock = Arc::new(Mutex::new(()));
133 let released = Arc::new(AtomicBool::new(false));
134 let waits = Arc::new(AtomicUsize::new(0));
135
136 let downloader = {
137 let lock = lock.clone();
138 let released = released.clone();
139 std::thread::spawn(move || {
140 let guard = lock.lock().unwrap();
141 std::thread::sleep(Duration::from_millis(400));
143 released.store(true, Ordering::Release);
144 drop(guard);
145 })
146 };
147
148 while lock.try_lock().is_ok() {
150 std::thread::sleep(Duration::from_millis(10));
151 }
152
153 let guard = hold_image_pull(
154 &lock,
155 || false,
156 || {
157 waits.fetch_add(1, Ordering::Release);
158 },
159 )
160 .expect("the launch takes the lock once the download finishes");
161 assert!(
162 released.load(Ordering::Acquire),
163 "the launch proceeded while the download still held the lock"
164 );
165 assert_eq!(
166 waits.load(Ordering::Acquire),
167 1,
168 "the wait should be announced exactly once"
169 );
170 drop(guard);
171 downloader.join().expect("the download thread finishes");
172 }
173
174 #[test]
177 fn a_waiting_create_stops_when_cancelled() {
178 let lock = Mutex::new(());
179 let held = lock.lock().unwrap();
180
181 let error = hold_image_pull(&lock, || true, || {})
182 .expect_err("a cancelled wait must not return a lock it never took");
183 assert!(
184 format!("{error:#}").contains("cancelled while waiting for image download"),
185 "{error:#}"
186 );
187 drop(held);
188 }
189
190 #[test]
193 fn the_pull_lock_is_shared_per_host_and_image() {
194 let first = image_pull_mutex(&ImageHost::LocalPodman, "ghcr.io/example/dev:latest");
195 let again = image_pull_mutex(&ImageHost::LocalPodman, "ghcr.io/example/dev:latest");
196 assert!(Arc::ptr_eq(&first, &again));
197
198 let other_image = image_pull_mutex(&ImageHost::LocalPodman, "ghcr.io/example/other:latest");
199 assert!(!Arc::ptr_eq(&first, &other_image));
200
201 let other_host = image_pull_mutex(&ImageHost::LocalDocker, "ghcr.io/example/dev:latest");
202 assert!(!Arc::ptr_eq(&first, &other_host));
203 }
204}