Skip to main content

mj_controller/
image_pull_gate.rs

1//! Coordination between the daemon's background image download and a session
2//! launch that needs the same image.
3//!
4//! The daemon downloads every configured container image shortly after it
5//! starts. A person who creates a session during that download must not start
6//! a second download of the same image: the two would compete for the same
7//! bandwidth and the same layer store. Instead the launch waits for the one
8//! already running, and then finds the image present.
9//!
10//! There is one lock per (host, image) pair. Whoever is allowed to download
11//! holds it; whoever needs the image waits on it.
12
13use 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
23/// How long a waiter sleeps between attempts on the lock.
24///
25/// A download runs for minutes, so polling this slowly costs nothing and keeps
26/// the wait cancellable without a condition variable: both the refresher and a
27/// launch run on blocking threads through the synchronous `CommandExecutor`,
28/// and either can be asked to stop while it waits.
29const POLL_INTERVAL: Duration = Duration::from_millis(250);
30
31/// The lock covering downloads of one image on one host.
32///
33/// Keyed by host label and image reference, which is what identifies a copy of
34/// an image: two targets naming the same image on the same host share one
35/// download, and the same image on two hosts does not.
36pub(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
54/// Take the right to download one image, waiting for whoever holds it.
55///
56/// `on_wait` runs once, the first time the lock is found busy, so a caller can
57/// tell the user it is waiting without saying anything in the common case
58/// where nothing is downloading. `is_cancelled` is polled while waiting so a
59/// quitting daemon or a cancelled Create does not sit here for minutes.
60pub(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            // A holder that panicked left no state behind: the lock guards
70            // nothing but the right to run a download.
71            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
85/// Run `work` with nothing downloading this target's image underneath it.
86///
87/// For a container target this waits for a background download of the same
88/// image on the same host, and reports the wait as the "Pull image" stage with
89/// a notice saying what it is waiting for. Nothing extra is reported in the
90/// ordinary case where no download is running, and a target that runs no image
91/// just runs `work`.
92pub(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    // The stage guard is created inside `on_wait`, so it exists only on the
103    // waiting path, and dropped as soon as the wait is over: the stage
104    // describes the wait, not the work that follows it.
105    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    /// A Create issued while the daemon is downloading the image waits for
129    /// that download instead of starting a second one, and says so once.
130    #[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                // Long enough that the waiter has to block on it.
142                std::thread::sleep(Duration::from_millis(400));
143                released.store(true, Ordering::Release);
144                drop(guard);
145            })
146        };
147
148        // Make sure the downloader holds the lock before the launch tries.
149        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    /// A cancelled Create stops waiting instead of sitting behind a
175    /// multi-gigabyte download.
176    #[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    /// The same image on the same host is one download; a different host or a
191    /// different image is not.
192    #[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}