mj_controller/
image_pull_gate.rs1use std::collections::BTreeMap;
19use std::fs::File;
20use std::path::PathBuf;
21use std::sync::{Arc, Mutex, MutexGuard};
22use std::time::Duration;
23
24use anyhow::{Context, Result, bail};
25
26use mj_core::targets::{
27 CommandExecutor, ImageHost, ProvisionStage, ProvisionStageGuard, TargetTemplate,
28};
29
30const POLL_INTERVAL: Duration = Duration::from_millis(250);
37
38pub(crate) fn image_pull_mutex(host: &ImageHost, image: &str) -> Arc<ImagePullLock> {
44 static LOCKS: std::sync::OnceLock<Mutex<BTreeMap<String, std::sync::Weak<ImagePullLock>>>> =
45 std::sync::OnceLock::new();
46 let key = format!("{}|{image}", host.label());
47 let mut locks = LOCKS
48 .get_or_init(Mutex::default)
49 .lock()
50 .unwrap_or_else(std::sync::PoisonError::into_inner);
51 locks.retain(|_, lock| lock.strong_count() > 0);
52 let file = (!cfg!(test)).then(|| lock_file_path(&key));
55 let slot = locks.entry(key).or_default();
56 if let Some(lock) = slot.upgrade() {
57 return lock;
58 }
59 let lock = Arc::new(ImagePullLock {
60 local: Mutex::new(()),
61 file,
62 });
63 *slot = Arc::downgrade(&lock);
64 lock
65}
66
67fn lock_file_path(key: &str) -> PathBuf {
71 use sha2::{Digest, Sha256};
72 let digest = Sha256::digest(key.as_bytes());
73 let name: String = digest
74 .iter()
75 .take(12)
76 .map(|byte| format!("{byte:02x}"))
77 .collect();
78 mj_core::config::data_dir()
79 .join("image-pulls")
80 .join(format!("{name}.lock"))
81}
82
83pub(crate) struct ImagePullLock {
86 local: Mutex<()>,
87 file: Option<PathBuf>,
88}
89
90impl ImagePullLock {
91 #[cfg(test)]
93 fn in_process() -> Self {
94 Self {
95 local: Mutex::new(()),
96 file: None,
97 }
98 }
99}
100
101#[derive(Debug)]
103pub(crate) struct ImagePullGuard<'a> {
104 _local: MutexGuard<'a, ()>,
105 _file: Option<File>,
106}
107
108fn try_lock_file(path: &std::path::Path) -> Result<Option<File>> {
110 if let Some(parent) = path.parent() {
111 std::fs::create_dir_all(parent).with_context(|| {
112 format!("create image download lock directory {}", parent.display())
113 })?;
114 }
115 let file = std::fs::OpenOptions::new()
116 .create(true)
117 .truncate(false)
118 .write(true)
119 .open(path)
120 .with_context(|| format!("open image download lock {}", path.display()))?;
121 match file.try_lock() {
122 Ok(()) => Ok(Some(file)),
123 Err(std::fs::TryLockError::WouldBlock) => Ok(None),
124 Err(std::fs::TryLockError::Error(error)) => {
125 Err(error).with_context(|| format!("lock image download lock {}", path.display()))
126 }
127 }
128}
129
130pub(crate) fn hold_image_pull<'a>(
137 lock: &'a ImagePullLock,
138 is_cancelled: impl Fn() -> bool,
139 on_wait: impl FnOnce(),
140) -> Result<ImagePullGuard<'a>> {
141 let mut on_wait = Some(on_wait);
142 loop {
143 let local = match lock.local.try_lock() {
144 Ok(guard) => Some(guard),
145 Err(std::sync::TryLockError::Poisoned(poisoned)) => Some(poisoned.into_inner()),
148 Err(std::sync::TryLockError::WouldBlock) => None,
149 };
150 if let Some(local) = local {
151 let file = match &lock.file {
152 Some(path) => try_lock_file(path)?.map(Some),
153 None => Some(None),
154 };
155 if let Some(file) = file {
156 return Ok(ImagePullGuard {
157 _local: local,
158 _file: file,
159 });
160 }
161 drop(local);
164 }
165 if let Some(on_wait) = on_wait.take() {
166 on_wait();
167 }
168 if is_cancelled() {
169 bail!("cancelled while waiting for image download");
170 }
171 std::thread::sleep(POLL_INTERVAL);
172 }
173}
174
175pub(crate) fn with_image_ready<T>(
183 target: &TargetTemplate,
184 executor: &impl CommandExecutor,
185 work: impl FnOnce() -> Result<T>,
186) -> Result<T> {
187 let Some((host, container)) = target.image_host() else {
188 return work();
189 };
190 let image = container.image.clone();
191 let lock = image_pull_mutex(&host, &image);
192 let mut waiting = None;
196 let guard = hold_image_pull(
197 &lock,
198 || executor.cancellation_requested(),
199 || {
200 waiting = Some(ProvisionStageGuard::new(
201 executor,
202 ProvisionStage::PullingImage,
203 ));
204 executor.notify_notice(&format!("Waiting for image {image} to finish downloading"));
205 },
206 )?;
207 drop(waiting);
208 let result = work();
209 drop(guard);
210 result
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
217
218 #[test]
221 fn a_create_waits_for_the_in_flight_pull_of_its_image() {
222 let lock = Arc::new(ImagePullLock::in_process());
223 let released = Arc::new(AtomicBool::new(false));
224 let waits = Arc::new(AtomicUsize::new(0));
225
226 let downloader = {
227 let lock = lock.clone();
228 let released = released.clone();
229 std::thread::spawn(move || {
230 let guard = lock.local.lock().unwrap();
231 std::thread::sleep(Duration::from_millis(400));
233 released.store(true, Ordering::Release);
234 drop(guard);
235 })
236 };
237
238 while lock.local.try_lock().is_ok() {
240 std::thread::sleep(Duration::from_millis(10));
241 }
242
243 let guard = hold_image_pull(
244 &lock,
245 || false,
246 || {
247 waits.fetch_add(1, Ordering::Release);
248 },
249 )
250 .expect("the launch takes the lock once the download finishes");
251 assert!(
252 released.load(Ordering::Acquire),
253 "the launch proceeded while the download still held the lock"
254 );
255 assert_eq!(
256 waits.load(Ordering::Acquire),
257 1,
258 "the wait should be announced exactly once"
259 );
260 drop(guard);
261 downloader.join().expect("the download thread finishes");
262 }
263
264 #[test]
267 fn a_waiting_create_stops_when_cancelled() {
268 let lock = ImagePullLock::in_process();
269 let held = lock.local.lock().unwrap();
270
271 let error = hold_image_pull(&lock, || true, || {})
272 .expect_err("a cancelled wait must not return a lock it never took");
273 assert!(
274 format!("{error:#}").contains("cancelled while waiting for image download"),
275 "{error:#}"
276 );
277 drop(held);
278 }
279
280 #[test]
285 fn a_smoke_test_in_another_process_waits_for_the_daemon_s_download() {
286 let dir = tempfile::tempdir().unwrap();
287 let path = dir.path().join("pulls").join("image.lock");
288 let daemon = ImagePullLock {
289 local: Mutex::new(()),
290 file: Some(path.clone()),
291 };
292 let setup = ImagePullLock {
293 local: Mutex::new(()),
294 file: Some(path),
295 };
296 let holding = AtomicBool::new(false);
297 let released = AtomicBool::new(false);
298 let waits = AtomicUsize::new(0);
299 std::thread::scope(|scope| {
300 scope.spawn(|| {
301 let held = hold_image_pull(&daemon, || false, || {}).unwrap();
302 holding.store(true, Ordering::Release);
303 std::thread::sleep(Duration::from_millis(400));
304 released.store(true, Ordering::Release);
305 drop(held);
306 });
307 while !holding.load(Ordering::Acquire) {
308 std::thread::sleep(Duration::from_millis(10));
309 }
310 let guard = hold_image_pull(
311 &setup,
312 || false,
313 || {
314 waits.fetch_add(1, Ordering::Release);
315 },
316 )
317 .unwrap();
318 assert!(released.load(Ordering::Acquire));
319 drop(guard);
320 });
321 assert_eq!(waits.load(Ordering::Acquire), 1);
322 }
323
324 #[test]
327 fn the_pull_lock_is_shared_per_host_and_image() {
328 let first = image_pull_mutex(&ImageHost::LocalPodman, "ghcr.io/example/dev:latest");
329 let again = image_pull_mutex(&ImageHost::LocalPodman, "ghcr.io/example/dev:latest");
330 assert!(Arc::ptr_eq(&first, &again));
331
332 let other_image = image_pull_mutex(&ImageHost::LocalPodman, "ghcr.io/example/other:latest");
333 assert!(!Arc::ptr_eq(&first, &other_image));
334
335 let other_host = image_pull_mutex(&ImageHost::LocalDocker, "ghcr.io/example/dev:latest");
336 assert!(!Arc::ptr_eq(&first, &other_host));
337 }
338}