Skip to main content

mj_controller/controller/
mbx.rs

1//! The shared mbx build cache for Rust container sessions.
2//!
3//! mbx wraps Cargo: a binary named `cargo` that is really `mbx` intercepts the
4//! build, looks every compiler action up in a content-addressed store, and
5//! restores cached outputs instead of recompiling. Its store is an ordinary
6//! directory on the container host, which every mj container on that host
7//! mounts read-write at the same absolute path. Nothing is synchronized
8//! between hosts and mj never runs mbx garbage collection; it only tells mbx
9//! when a workspace it removed will never build again (see [`release`]).
10//!
11//! Cache discovery can leave new sessions uncached. Once a cache is selected,
12//! configuration failures are reported rather than launching with stale policy.
13
14mod configuration;
15pub(crate) mod install;
16pub(crate) mod release;
17pub(crate) mod service;
18
19use std::path::{Path, PathBuf};
20use std::time::{Duration, Instant};
21
22use anyhow::{Context, Result, bail, ensure};
23
24use super::cache_host::CacheHost;
25use crate::targets::{self, CommandExecutor, CommandOutput, CommandSpec};
26use mj_core::config::{Config, TargetBuildCache, TargetTemplate};
27use mj_core::state::{
28    BuildCacheApplication, BuildCacheLimit, BuildCacheOff, BuildCachePreview, BuildCacheStats,
29    SessionBuildCache,
30};
31
32/// The minimum mbx version whose cache format Mjolnir can share.
33pub(crate) const MBX_VERSION: &str = "1.22.0";
34
35const MBX_COPY_RELATIVE: &str = ".mjolnir/bin/mbx";
36/// mbx's running totals, relative to the cache directory.
37const TALLY_RELATIVE: &str = "actions/savings/v1/tally.json";
38const RESOLUTION_LIFETIME: Duration = Duration::from_secs(600);
39const LABEL: &str = "hel-mbx";
40const UNSUPPORTED_HOST: &str = "Mjolnir's shared mbx cache requires a Linux host. Native mbx on macOS must be installed and configured separately.";
41
42/// Ask the cache host, not the controller or a container running on that host.
43fn host_supports_cache(host: &CacheHost, executor: &impl CommandExecutor) -> Result<bool> {
44    // Windows is no Linux host and has no `uname` to ask; its container
45    // engine runs in a VM this machine's paths do not reach.
46    if host.ssh().is_none() && !cfg!(unix) {
47        return Ok(false);
48    }
49    let command = host.command(
50        vec!["uname".into(), "-sm".into()],
51        "detect build cache host platform",
52    );
53    let output = checked(executor.execute(&command)?, &command)?;
54    let platform = targets::TargetPlatform::parse(
55        std::str::from_utf8(&output.stdout).context("decode build cache host platform")?,
56    )?;
57    Ok(platform.os == targets::TargetOs::Linux)
58}
59
60/// What a container target's host offers as a build cache.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub(super) struct ResolvedBuildCache {
63    /// Cache directory on the host, mounted at the same path in the container.
64    pub directory: PathBuf,
65    /// The compatible native mbx executable used to refresh the cache copy.
66    pub native_mbx: NativeMbx,
67    /// A `[target] root` the host configuration relocates outside the cache
68    /// directory, which the container needs mounted at the same path too.
69    pub target_root: Option<PathBuf>,
70    /// Desired policy for the shared machine file, never a private session copy.
71    pub config_file: Option<String>,
72    pub config_directory: PathBuf,
73    pub previous_config: Option<String>,
74}
75
76/// Cached host inspections, keyed by host and per-target settings. An
77/// inspection runs several commands on the host, and a burst of new sessions
78/// must not repeat them for each one. A failure is remembered too, so a host
79/// that cannot answer is not re-probed by every session in that burst.
80type Resolutions = std::collections::BTreeMap<String, (Instant, Result<Inspection, String>)>;
81
82static RESOLUTIONS: std::sync::LazyLock<std::sync::Mutex<Resolutions>> =
83    std::sync::LazyLock::new(|| std::sync::Mutex::new(Resolutions::new()));
84
85type Applications = std::collections::BTreeMap<String, Result<(), String>>;
86static APPLICATIONS: std::sync::LazyLock<std::sync::Mutex<Applications>> =
87    std::sync::LazyLock::new(Default::default);
88
89type MbxSyncLocks = std::collections::BTreeMap<String, std::sync::Arc<std::sync::Mutex<()>>>;
90static MBX_SYNC_LOCKS: std::sync::LazyLock<std::sync::Mutex<MbxSyncLocks>> =
91    std::sync::LazyLock::new(Default::default);
92
93/// Whether a caller can be served a memoized answer or needs the host asked
94/// again.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96enum Freshness {
97    /// Provisioning: an answer from the last `RESOLUTION_LIFETIME` will do.
98    Memoized,
99    /// The settings screen, which is read precisely when somebody has just
100    /// changed something on the host. A fresh answer also replaces the
101    /// memoized one, so the next session sees the same thing the screen does.
102    Fresh,
103}
104
105/// The one place a host is inspected. Sessions and the settings screen differ
106/// only in the freshness they ask for, so they cannot drift into reporting
107/// different things about the same host.
108fn inspect(
109    host: &CacheHost,
110    settings: &TargetBuildCache,
111    freshness: Freshness,
112    executor: &impl CommandExecutor,
113) -> Result<Inspection> {
114    let key = format!("{}|{settings:?}", host.key());
115    if freshness == Freshness::Memoized
116        && let Some((recorded, inspection)) = RESOLUTIONS.lock().expect("mbx resolutions").get(&key)
117        && recorded.elapsed() < RESOLUTION_LIFETIME
118    {
119        return inspection.clone().map_err(|error| anyhow::anyhow!(error));
120    }
121    let inspection = inspect_host(host, settings, executor);
122    let recorded = match &inspection {
123        Ok(inspection) => Ok(inspection.clone()),
124        Err(error) => Err(format!("{error:#}")),
125    };
126    RESOLUTIONS
127        .lock()
128        .expect("mbx resolutions")
129        .insert(key, (Instant::now(), recorded));
130    inspection
131}
132
133/// Resolve the build cache for one container target, or `None` when this
134/// target runs without one.
135pub(super) fn resolve(
136    target: &targets::TargetTemplate,
137    executor: &impl CommandExecutor,
138) -> Option<ResolvedBuildCache> {
139    let (host, settings) = supported_host(target)?;
140    let inspection = match inspect(&host, &settings, Freshness::Memoized, executor) {
141        Ok(inspection) => inspection,
142        Err(error) => {
143            tracing::warn!(host = host.key(), "build cache unavailable: {error:#}");
144            return None;
145        }
146    };
147    let Some(cache) = inspection.cache else {
148        if let Some(reason) = &inspection.preview.off_reason {
149            tracing::warn!(
150                directory = inspection
151                    .preview
152                    .directory
153                    .as_ref()
154                    .map(|directory| directory.display().to_string()),
155                "sessions on this target run without the build cache: {reason}"
156            );
157        }
158        return None;
159    };
160    // Creating the directory is the one side effect a session has and the
161    // settings screen does not, so it sits here rather than inside the shared
162    // inspection. It runs per session because a memoized inspection says what
163    // the host looked like, not that the directory still exists.
164    if let Err(error) = create_directory(&host, &cache.directory, executor) {
165        tracing::warn!(
166            directory = %cache.directory.display(),
167            "the build cache directory could not be created: {error:#}"
168        );
169        return None;
170    }
171    match apply_cache(&host, &settings, cache, executor) {
172        Ok(cache) => Some(cache),
173        Err(error) => {
174            tracing::warn!(
175                host = host.key(),
176                "applying machine build cache configuration failed: {error:#}"
177            );
178            executor.notify_notice(&format!(
179                "Build cache configuration could not be applied: {error:#}"
180            ));
181            None
182        }
183    }
184}
185
186fn apply_cache(
187    host: &CacheHost,
188    settings: &TargetBuildCache,
189    mut cache: ResolvedBuildCache,
190    executor: &impl CommandExecutor,
191) -> Result<ResolvedBuildCache> {
192    let key = format!("{}|{settings:?}", host.key());
193    let result = (|| {
194        if !configuration::apply(host, &cache, executor)? {
195            // Another application won. Accept it only if it already installs
196            // this policy; never replay an older desired value over a newer one.
197            cache = inspect(host, settings, Freshness::Fresh, executor)?
198                .cache
199                .context("build cache became unavailable during application")?;
200            ensure!(
201                cache.previous_config == cache.config_file,
202                "machine build cache policy changed during application; retry with current machine settings"
203            );
204        }
205        cache.previous_config = cache.config_file.clone();
206        if let Some((_, Ok(inspection))) =
207            RESOLUTIONS.lock().expect("mbx resolutions").get_mut(&key)
208        {
209            inspection.cache = Some(cache.clone());
210            inspection.preview.application = BuildCacheApplication::Applied;
211        }
212        Ok(cache)
213    })();
214    APPLICATIONS.lock().expect("mbx applications").insert(
215        key,
216        result
217            .as_ref()
218            .map(|_| ())
219            .map_err(|error| format!("{error:#}")),
220    );
221    result
222}
223
224/// The targets that can share a host build cache. Apple `container` runs each
225/// container in its own virtual machine, where file locks across the shared
226/// store are unverified, and bare and EC2 targets are out of scope.
227fn supported_host(target: &targets::TargetTemplate) -> Option<(CacheHost, TargetBuildCache)> {
228    let settings = match target {
229        targets::TargetTemplate::LocalPodman(container)
230        | targets::TargetTemplate::LocalDocker(container)
231        | targets::TargetTemplate::SshPodman { container, .. }
232        | targets::TargetTemplate::SshDocker { container, .. } => {
233            container.build_cache.clone().unwrap_or_default()
234        }
235        targets::TargetTemplate::AppleContainer(_)
236        | targets::TargetTemplate::LocalBare
237        | targets::TargetTemplate::AwsEc2(_)
238        | targets::TargetTemplate::SshBare { .. } => return None,
239    };
240    Some((CacheHost::for_target(target)?, settings))
241}
242
243/// What the settings screen shows for one machine's blank build cache fields:
244/// the same host inspection a session runs, without creating the directory.
245/// `None` when the machine has no standing host to share a cache on.
246pub fn preview_build_cache(
247    machine: &mj_core::config::Machine,
248    executor: &impl CommandExecutor,
249) -> Result<Option<BuildCachePreview>> {
250    let Some(host) = CacheHost::for_machine(machine) else {
251        return Ok(None);
252    };
253    let settings = machine.build_cache().cloned().unwrap_or_default();
254    let mut preview = inspect(&host, &settings, Freshness::Fresh, executor)?.preview;
255    if install::install_kind(&preview).is_some() {
256        let profile = install::login_profile_details(&host, executor)?;
257        preview.mbx_profile_file = Some(profile.file);
258        preview.mbx_profile_warning = profile.warning;
259        preview.mbx_manual_path_line = profile.manual_path_line;
260    }
261    Ok(Some(preview))
262}
263
264/// Apply one machine's desired policy. Both provisioning and the daemon use
265/// the same compare-and-replace operation; native settings are only read.
266pub(crate) fn apply_machine_build_cache(
267    machine: &mj_core::config::Machine,
268    mounted_directories: &[PathBuf],
269    executor: &impl CommandExecutor,
270) -> Result<()> {
271    let Some(host) = CacheHost::for_machine(machine) else {
272        return Ok(());
273    };
274    let settings = machine.build_cache().cloned().unwrap_or_default();
275    let inspected = inspect(&host, &settings, Freshness::Fresh, executor)?;
276    if matches!(
277        &inspected.preview.off_reason,
278        Some(BuildCacheOff::Unavailable(reason)) if reason == UNSUPPORTED_HOST
279    ) {
280        return Ok(());
281    }
282    let cache = inspected
283        .cache
284        .map(|cache| apply_cache(&host, &settings, cache, executor))
285        .transpose()?;
286
287    let native = if let Some(cache) = &cache {
288        Some(cache.native_mbx.clone())
289    } else {
290        match classify_native_mbx(probe_native_version(&host, executor)?) {
291            NativeMbxStatus::Compatible(native) => Some(native),
292            NativeMbxStatus::Absent
293            | NativeMbxStatus::TooOld(_)
294            | NativeMbxStatus::Unknown { .. } => None,
295        }
296    };
297    let Some(native) = native else {
298        // Existing copies remain usable by their mounted containers until the
299        // host has a compatible native mbx again.
300        return Ok(());
301    };
302
303    let mut directories = mounted_directories.to_vec();
304    if let Some(cache) = &cache
305        && !directories.contains(&cache.directory)
306    {
307        directories.push(cache.directory.clone());
308    }
309    for directory in directories {
310        // Existing containers retain their mounts if placement changes. Apply
311        // policy there as before, then refresh every copy those mounts expose.
312        if let Some(cache) = &cache
313            && directory != cache.directory
314        {
315            publish_at(&host, cache, &directory, executor)?;
316        }
317        sync_mbx_binary_from_native(&host, &native, &directory, executor)?;
318    }
319    Ok(())
320}
321
322fn publish_at(
323    host: &CacheHost,
324    cache: &ResolvedBuildCache,
325    directory: &Path,
326    executor: &impl CommandExecutor,
327) -> Result<PathBuf> {
328    let mut projected = cache.clone();
329    projected.config_directory = configuration::shared_directory(directory);
330    projected.previous_config = configuration::read_file(
331        host,
332        &projected.config_directory.join("config.toml"),
333        executor,
334    )?;
335    ensure!(
336        configuration::apply(host, &projected, executor)?,
337        "machine configuration changed during application; retry with current settings"
338    );
339    Ok(projected.config_directory)
340}
341
342/// Upgrade an existing container through its existing cache mount. Resolving
343/// ownership uses its actual host, never a target name that can be reassigned.
344pub(super) fn prepare_session_configuration(
345    config: &Config,
346    backend: &targets::TargetLocator,
347    recorded: &SessionBuildCache,
348    executor: &impl CommandExecutor,
349) -> Result<PathBuf> {
350    let host = host_for_locator(backend).context("build cache has no container host")?;
351    let mut settings = config
352        .machines
353        .values()
354        .filter(|machine| {
355            CacheHost::for_machine(machine).is_some_and(|candidate| candidate.key() == host.key())
356        })
357        .filter_map(|machine| machine.build_cache())
358        .next()
359        .cloned()
360        .unwrap_or_default();
361    settings.directory = Some(recorded.directory.clone());
362    // A disabled cache still exists in already provisioned containers. Keep
363    // its policy current until the session no longer mounts it.
364    settings.enabled = Some(true);
365    let inspected = inspect_host(&host, &settings, executor)?;
366    let cache = inspected.cache.with_context(|| {
367        format!(
368            "build cache configuration unavailable: {}",
369            inspected
370                .preview
371                .off_reason
372                .map(|reason| reason.to_string())
373                .unwrap_or_else(|| "host inspection returned no cache".into())
374        )
375    })?;
376    publish_at(&host, &cache, &recorded.directory, executor)
377}
378
379/// The configuration file reachable through a session's shared cache mount.
380pub(super) fn shared_configuration_file(directory: &Path) -> PathBuf {
381    configuration::shared_directory(directory).join("config.toml")
382}
383
384/// Native mbx compatibility for a host used by configured container targets.
385pub(crate) struct DoctorHostMbx {
386    pub host: String,
387    pub targets: Vec<String>,
388    pub status: DoctorHostMbxStatus,
389}
390
391pub(crate) enum DoctorHostMbxStatus {
392    Unsupported(String),
393    Absent,
394    Compatible(String),
395    TooOld(String),
396    Unknown(String),
397}
398
399/// One classification of a host probe, shared by doctor and session preview.
400enum NativeMbxStatus {
401    Absent,
402    Compatible(NativeMbx),
403    TooOld(NativeMbx),
404    Unknown { version: String, reason: String },
405}
406
407fn classify_native_mbx(native: Option<NativeMbx>) -> NativeMbxStatus {
408    let Some(native) = native else {
409        return NativeMbxStatus::Absent;
410    };
411    match semver::Version::parse(&native.version) {
412        Ok(_) if version_at_least(&native.version, MBX_VERSION) => {
413            NativeMbxStatus::Compatible(native)
414        }
415        Ok(_) => NativeMbxStatus::TooOld(native),
416        Err(_) => NativeMbxStatus::Unknown {
417            reason: format!(
418                "the host reported an unrecognized mbx version {:?}",
419                native.version
420            ),
421            version: native.version,
422        },
423    }
424}
425
426pub(super) fn version_at_least(found: &str, required: &str) -> bool {
427    matches!(
428        (semver::Version::parse(found), semver::Version::parse(required)),
429        (Ok(found), Ok(required)) if found >= required
430    )
431}
432
433fn cache_unavailable_reason(status: &NativeMbxStatus) -> String {
434    match status {
435        NativeMbxStatus::Absent => {
436            "mbx is not installed on the container host. Install mbx from Settings › Setup › Machines to enable the shared build cache.".into()
437        }
438        NativeMbxStatus::TooOld(native) => format!(
439            "host mbx {} is older than the minimum supported version {}; upgrade mbx from Settings › Setup › Machines to enable the shared build cache",
440            native.version, MBX_VERSION
441        ),
442        NativeMbxStatus::Unknown { reason, .. } => format!(
443            "{reason}; install or upgrade mbx from Settings › Setup › Machines to enable the shared build cache"
444        ),
445        NativeMbxStatus::Compatible(_) => unreachable!("compatible mbx enables the cache"),
446    }
447}
448
449/// Check each relevant host once. The cache is optional, so disabled caches
450/// and hosts with no Podman or Docker target need no compatibility check.
451pub(crate) fn doctor_host_mbx(
452    config: &Config,
453    executor: &impl CommandExecutor,
454) -> Vec<DoctorHostMbx> {
455    let mut hosts: std::collections::BTreeMap<String, (CacheHost, Vec<String>)> =
456        std::collections::BTreeMap::new();
457    let mut checks = Vec::new();
458    for (id, target) in &config.targets {
459        let container = match target {
460            TargetTemplate::LocalPodman { container }
461            | TargetTemplate::LocalDocker { container }
462            | TargetTemplate::SshPodman { container, .. }
463            | TargetTemplate::SshDocker { container, .. } => container,
464            _ => continue,
465        };
466        if container
467            .build_cache
468            .as_ref()
469            .and_then(|cache| cache.enabled)
470            == Some(false)
471        {
472            continue;
473        }
474        match CacheHost::for_path_target(target) {
475            Ok(host) => {
476                let key = host.key();
477                hosts
478                    .entry(key)
479                    .or_insert_with(|| (host, Vec::new()))
480                    .1
481                    .push(id.clone());
482            }
483            Err(error) => checks.push(DoctorHostMbx {
484                host: id.clone(),
485                targets: vec![id.clone()],
486                status: DoctorHostMbxStatus::Unknown(format!("{error:#}")),
487            }),
488        }
489    }
490    checks.extend(hosts.into_iter().map(|(key, (host, targets))| {
491        let status = (|| -> Result<DoctorHostMbxStatus> {
492            if !host_supports_cache(&host, executor)? {
493                return Ok(DoctorHostMbxStatus::Unsupported(UNSUPPORTED_HOST.into()));
494            }
495            Ok(
496                match classify_native_mbx(probe_native_version(&host, executor)?) {
497                    NativeMbxStatus::Absent => DoctorHostMbxStatus::Absent,
498                    NativeMbxStatus::Compatible(native) => {
499                        DoctorHostMbxStatus::Compatible(native.version)
500                    }
501                    NativeMbxStatus::TooOld(native) => DoctorHostMbxStatus::TooOld(native.version),
502                    NativeMbxStatus::Unknown { reason, .. } => DoctorHostMbxStatus::Unknown(reason),
503                },
504            )
505        })()
506        .unwrap_or_else(|error| DoctorHostMbxStatus::Unknown(format!("{error:#}")));
507        DoctorHostMbx {
508            host: key,
509            targets,
510            status,
511        }
512    }));
513    checks
514}
515
516/// Everything the host says about a target's build cache, read without
517/// changing the host.
518#[derive(Clone)]
519struct Inspection {
520    preview: BuildCachePreview,
521    /// The cache a session would mount, or `None` when it runs without one.
522    cache: Option<ResolvedBuildCache>,
523}
524
525fn inspect_host(
526    host: &CacheHost,
527    settings: &TargetBuildCache,
528    executor: &impl CommandExecutor,
529) -> Result<Inspection> {
530    if !host_supports_cache(host, executor)? {
531        return Ok(Inspection {
532            preview: BuildCachePreview {
533                native_mbx: None,
534                mbx_profile_file: None,
535                mbx_profile_warning: None,
536                mbx_manual_path_line: None,
537                directory: None,
538                max_total_size: None,
539                user_managed: false,
540                application: BuildCacheApplication::Pending,
541                budget_note: None,
542                stats: None,
543                off_reason: Some(BuildCacheOff::Unavailable(UNSUPPORTED_HOST.into())),
544            },
545            cache: None,
546        });
547    }
548    let off = |preview: BuildCachePreview| Inspection {
549        preview,
550        cache: None,
551    };
552    let compatibility = classify_native_mbx(probe_native_version(host, executor)?);
553    let native = match compatibility {
554        NativeMbxStatus::Compatible(native) => native,
555        status => {
556            let native_version = match &status {
557                NativeMbxStatus::TooOld(native) => Some(native.version.clone()),
558                NativeMbxStatus::Unknown { version, .. } => Some(version.clone()),
559                NativeMbxStatus::Absent | NativeMbxStatus::Compatible(_) => None,
560            };
561            return Ok(off(BuildCachePreview {
562                native_mbx: native_version,
563                mbx_profile_file: None,
564                mbx_profile_warning: None,
565                mbx_manual_path_line: None,
566                directory: None,
567                max_total_size: None,
568                user_managed: false,
569                application: BuildCacheApplication::Pending,
570                budget_note: None,
571                stats: None,
572                off_reason: Some(BuildCacheOff::Unavailable(cache_unavailable_reason(
573                    &status,
574                ))),
575            }));
576        }
577    };
578    let native_version = Some(native.version.clone());
579    let directory = native_cache_directory(host, &native, executor)?;
580    ensure!(
581        directory.is_absolute(),
582        "build cache directory {} is not absolute",
583        directory.display()
584    );
585
586    let user_managed = true;
587    let config_directory = configuration::shared_directory(&directory);
588    let previous_config =
589        configuration::read_file(host, &config_directory.join("config.toml"), executor)?;
590    let text = host_config_file(host, executor)?;
591    let limit = match configuration::configured_limit(text.as_deref(), "gc", "max_total_size")? {
592        Some(size) => BuildCacheLimit::HostConfiguration(Some(size)),
593        None => BuildCacheLimit::MbxDefault(None),
594    };
595    let config_file = Some(text.unwrap_or_default());
596    let target_root = config_file
597        .as_deref()
598        .and_then(|text| relocated_target_root(text, &directory));
599    let mut application =
600        configuration::application(previous_config.as_deref(), config_file.as_deref());
601    if application == BuildCacheApplication::Pending
602        && let Some(Err(error)) = APPLICATIONS
603            .lock()
604            .expect("mbx applications")
605            .get(&format!("{}|{settings:?}", host.key()))
606    {
607        application = BuildCacheApplication::Failed(error.clone());
608    }
609    // Read before the checks below, so a host that cannot share the cache
610    // right now still reports what the cache did while it could.
611    let stats = read_stats(host, &directory, executor);
612    let preview = |off_reason: Option<BuildCacheOff>| BuildCachePreview {
613        native_mbx: native_version.clone(),
614        mbx_profile_file: None,
615        mbx_profile_warning: None,
616        mbx_manual_path_line: None,
617        directory: Some(directory.clone()),
618        max_total_size: Some(limit.clone()),
619        user_managed,
620        application: application.clone(),
621        budget_note: Some(
622            "One budget covers shared compiler outputs, managed worktrees, and incremental state."
623                .into(),
624        ),
625        stats: stats.clone(),
626        off_reason,
627    };
628
629    // The directory may not exist yet; its filesystem is its nearest
630    // existing ancestor's.
631    let volume = nearest_existing_ancestor(host, &directory, executor)?;
632    // A machine that is not turned off still has to support the cache: an
633    // explicit `enabled = true` cannot make a volume without reflinks usable.
634    if !settings.enabled.unwrap_or(true) {
635        return Ok(off(preview(Some(BuildCacheOff::TurnedOff))));
636    }
637    if !reflinks_supported(host, &volume, executor)? {
638        return Ok(off(preview(Some(BuildCacheOff::Unavailable(format!(
639            "the filesystem under {} does not support reflinks, so restoring cached \
640             outputs would copy every byte",
641            directory.display()
642        ))))));
643    }
644    if let Some(reason) = unusable_filesystem(host, &volume, executor)? {
645        return Ok(off(preview(Some(BuildCacheOff::Unavailable(format!(
646            "{} is on a {reason}, where mbx's file locks are unreliable",
647            directory.display()
648        ))))));
649    }
650
651    // A relocated target root is a separate mount, and a restore into it is a
652    // clone only when it shares one with the store. Copying instead is correct
653    // and much slower, and mbx's materializer falls back to it without saying
654    // so, which makes this the only place it can be noticed. It is reported
655    // rather than disqualifying: a slow cache still beats no cache.
656    if let Some(root) = &target_root {
657        let root_volume = nearest_existing_ancestor(host, root, executor)?;
658        if !cross_reflinks_supported(host, &volume, &root_volume, executor)? {
659            tracing::warn!(
660                cache = %directory.display(),
661                target_root = %root.display(),
662                "the host's mbx target root does not share a mount with the build cache, \
663                 so restoring a cached output copies every byte instead of cloning it"
664            );
665        }
666    }
667
668    Ok(Inspection {
669        preview: preview(None),
670        cache: Some(ResolvedBuildCache {
671            directory,
672            native_mbx: native,
673            target_root,
674            config_file,
675            config_directory,
676            previous_config,
677        }),
678    })
679}
680
681/// mbx's running totals for this cache, or `None` when it has none yet.
682///
683/// Read from the tally file rather than by running `mbx stats`, which also
684/// walks the content-addressed store to size it: that took 90 seconds on a
685/// 540 GB cache here, where the tally is a few hundred bytes. It also means
686/// the numbers need no mbx binary on the host.
687///
688/// A cache that has never been used has no tally, which is not a failure.
689fn read_stats(
690    host: &CacheHost,
691    directory: &Path,
692    executor: &impl CommandExecutor,
693) -> Option<BuildCacheStats> {
694    #[derive(Default, serde::Deserialize)]
695    #[serde(default)]
696    struct Tally {
697        builds: u64,
698        cached_compilations: u64,
699        avoided_compiler_ns: u64,
700        reflinked_bytes: u64,
701    }
702
703    let path = directory.join(TALLY_RELATIVE);
704    let command = host.shell_command(
705        READ_CONFIG_SCRIPT,
706        LABEL,
707        [path.to_string_lossy().into_owned()],
708        "read the container host build cache totals",
709    );
710    let output = executor.execute(&command).ok()?;
711    if output.status != 0 {
712        return None;
713    }
714    // A newer mbx may add counters; unknown ones are ignored rather than
715    // costing the whole report, exactly as mbx reads the file itself.
716    let tally: Tally = serde_json::from_slice(&output.stdout)
717        .inspect_err(|error| {
718            tracing::debug!(
719                path = %path.display(),
720                "the build cache totals could not be read: {error}"
721            );
722        })
723        .ok()?;
724    Some(BuildCacheStats {
725        builds: tally.builds,
726        cached_compilations: tally.cached_compilations,
727        avoided_compiler_ns: tally.avoided_compiler_ns,
728        reflinked_bytes: tally.reflinked_bytes,
729    })
730}
731
732/// The host's own mbx: its absolute resolved path and version.
733#[derive(Debug, Clone, PartialEq, Eq)]
734pub(crate) struct NativeMbx {
735    pub program: PathBuf,
736    pub version: String,
737}
738
739/// Find the same native executable whether it is on PATH or in a common
740/// per-user install directory, then resolve symlinks before reporting it.
741pub(super) const NATIVE_VERSION_SCRIPT: &str = r#"for candidate in "$(command -v mbx 2>/dev/null || true)" "$HOME/.local/bin/mbx" "$HOME/.cargo/bin/mbx"; do
742    [ -n "$candidate" ] && [ -x "$candidate" ] || continue
743    resolved=$(readlink -f -- "$candidate" 2>/dev/null) || continue
744    case "$resolved" in /*) ;; *) continue ;; esac
745    if version=$("$resolved" --version 2>/dev/null); then
746        printf '%s
747%s' "$resolved" "$version"
748        exit 0
749    fi
750done
751exit 1"#;
752
753/// The host's own mbx, or `None` when no supported candidate can run.
754pub(super) fn probe_native_version(
755    host: &CacheHost,
756    executor: &impl CommandExecutor,
757) -> Result<Option<NativeMbx>> {
758    let command = host.shell_command(
759        NATIVE_VERSION_SCRIPT,
760        LABEL,
761        [],
762        "read the container host mbx version",
763    );
764    let output = executor.execute(&command)?;
765    if output.status == 1 {
766        return Ok(None);
767    }
768    ensure!(
769        output.status == 0,
770        "mbx version probe exited with status {}",
771        output.status
772    );
773    parse_native_probe_output(&output.stdout).map(Some)
774}
775
776fn parse_native_probe_output(stdout: &[u8]) -> Result<NativeMbx> {
777    let text = String::from_utf8_lossy(stdout);
778    let (program, version) = text
779        .trim()
780        .split_once('\n')
781        .context("mbx version probe gave no version")?;
782    let version = version
783        .split_whitespace()
784        .next_back()
785        .context("mbx version probe gave an empty version")?;
786    ensure!(
787        Path::new(program).is_absolute(),
788        "mbx version probe returned a non-absolute executable path {program:?}"
789    );
790    Ok(NativeMbx {
791        program: PathBuf::from(program),
792        version: version.to_owned(),
793    })
794}
795
796/// The current native mbx copy visible through the shared cache mount.
797#[derive(Debug, Clone, PartialEq, Eq)]
798pub(super) struct CachedMbxBinary {
799    pub path: PathBuf,
800    pub version: String,
801}
802
803#[derive(Debug, Clone, PartialEq, Eq)]
804pub(super) enum CachedMbxSync {
805    Ready(CachedMbxBinary),
806    Unavailable(String),
807}
808
809const SYNC_MBX_BINARY_SCRIPT: &str = r#"set -eu
810source=$1 destination=$2 expected=$3 probe_script=$4
811directory=$(dirname -- "$destination")
812mkdir -p -- "$directory"
813if command -v flock >/dev/null 2>&1; then
814    exec 9>"$directory/.mbx.lock"
815    flock -x 9
816fi
817probe_matches() {
818    current=$(sh -c "$probe_script" 2>/dev/null) || return 1
819    newline='
820'
821    current_program=${current%%"$newline"*}
822    current_version=${current##* }
823    [ "$current_program" = "$source" ] && [ "$current_version" = "$expected" ]
824}
825if ! probe_matches; then
826    echo "native mbx changed while cache synchronization was waiting" >&2
827    exit 75
828fi
829source_output=$("$source" --version 2>/dev/null) || {
830    echo "native mbx stopped working during cache synchronization" >&2
831    exit 2
832}
833source_version=${source_output##* }
834[ "$source_version" = "$expected" ] || {
835    echo "native mbx changed version during cache synchronization" >&2
836    exit 75
837}
838source_size=$(wc -c < "$source")
839if [ -f "$destination" ] && [ ! -L "$destination" ] && [ -x "$destination" ]; then
840    installed_output=$("$destination" --version 2>/dev/null) || installed_output=
841    installed_version=${installed_output##* }
842    installed_size=$(wc -c < "$destination")
843    if [ "$installed_version" = "$expected" ] && [ "$source_size" -eq "$installed_size" ]; then
844        if ! probe_matches; then
845            echo "native mbx changed during cache synchronization" >&2
846            exit 75
847        fi
848        printf 'unchanged\n'
849        exit 0
850    fi
851fi
852temporary=$(mktemp "${destination}.mjolnir.XXXXXX")
853trap 'rm -f -- "$temporary"' EXIT HUP INT TERM
854cp -- "$source" "$temporary"
855chmod 755 -- "$temporary"
856temporary_output=$("$temporary" --version 2>/dev/null) || {
857    echo "copied mbx cannot run on the container host" >&2
858    exit 2
859}
860temporary_version=${temporary_output##* }
861temporary_size=$(wc -c < "$temporary")
862[ "$temporary_version" = "$expected" ] && [ "$source_size" -eq "$temporary_size" ] || {
863    echo "copied mbx changed during cache synchronization" >&2
864    exit 2
865}
866mv -f -- "$temporary" "$destination"
867trap - EXIT HUP INT TERM
868if ! probe_matches; then
869    echo "native mbx changed during cache synchronization" >&2
870    exit 75
871fi
872printf 'synced\n'"#;
873
874pub(super) fn cache_binary_path(directory: &Path) -> PathBuf {
875    directory.join(MBX_COPY_RELATIVE)
876}
877
878/// Copy the current compatible host executable into a cache directory that is
879/// already mounted read-write in its containers. A missing or old native mbx
880/// leaves any previous copy untouched.
881pub(super) fn sync_current_mbx_binary(
882    host: &CacheHost,
883    directory: &Path,
884    executor: &impl CommandExecutor,
885) -> Result<CachedMbxSync> {
886    match classify_native_mbx(probe_native_version(host, executor)?) {
887        NativeMbxStatus::Compatible(native) => Ok(CachedMbxSync::Ready(
888            sync_mbx_binary_from_native(host, &native, directory, executor)?,
889        )),
890        status => Ok(CachedMbxSync::Unavailable(cache_unavailable_reason(
891            &status,
892        ))),
893    }
894}
895
896/// Atomically refresh one cache copy from a version that has already been
897/// classified as compatible. Version and size avoid copying an unchanged
898/// multi-megabyte executable on every resume or reconciliation tick.
899pub(super) fn sync_mbx_binary_from_native(
900    host: &CacheHost,
901    native: &NativeMbx,
902    directory: &Path,
903    executor: &impl CommandExecutor,
904) -> Result<CachedMbxBinary> {
905    let mut native = match classify_native_mbx(Some(native.clone())) {
906        NativeMbxStatus::Compatible(native) => native,
907        status => bail!("{}", cache_unavailable_reason(&status)),
908    };
909    ensure!(
910        directory.is_absolute(),
911        "build cache directory is not absolute: {}",
912        directory.display()
913    );
914    let path = cache_binary_path(directory);
915    let key = format!("{}|{}", host.key(), path.display());
916    let lock = MBX_SYNC_LOCKS
917        .lock()
918        .expect("mbx sync locks")
919        .entry(key)
920        .or_insert_with(|| std::sync::Arc::new(std::sync::Mutex::new(())))
921        .clone();
922    let _guard = lock.lock().unwrap_or_else(|error| error.into_inner());
923    for attempt in 0..3 {
924        let command = host.shell_command(
925            SYNC_MBX_BINARY_SCRIPT,
926            LABEL,
927            [
928                native.program.to_string_lossy().into_owned(),
929                path.to_string_lossy().into_owned(),
930                native.version.clone(),
931                NATIVE_VERSION_SCRIPT.to_owned(),
932            ],
933            "synchronize native mbx into the shared cache",
934        );
935        let output = executor.execute(&command)?;
936        if output.status == 0 {
937            return Ok(CachedMbxBinary {
938                path,
939                version: native.version,
940            });
941        }
942        ensure!(
943            output.status == 75,
944            "{} failed with status {}: {}",
945            command.purpose,
946            output.status,
947            String::from_utf8_lossy(&output.stderr).trim()
948        );
949        let current = probe_native_version(host, executor)?;
950        native = match classify_native_mbx(current) {
951            NativeMbxStatus::Compatible(native) => native,
952            status => bail!("{}", cache_unavailable_reason(&status)),
953        };
954        if attempt == 2 {
955            bail!("native mbx kept changing during cache synchronization");
956        }
957    }
958    unreachable!("the bounded synchronization retry loop returns or fails")
959}
960
961/// The available column of a `df -P` report (the third field of its data row).
962/// Callers decide the block size used by the report they requested.
963pub(super) fn available_bytes(report: &str) -> Option<u64> {
964    let row = report
965        .lines()
966        .filter(|line| !line.trim().is_empty())
967        .nth(1)?;
968    let fields = row.split_whitespace().collect::<Vec<_>>();
969    fields.get(fields.len().checked_sub(3)?)?.parse().ok()
970}
971
972/// The host's own cache directory. `mbx cache dir` prints the store, which is
973/// the `actions` directory inside the cache directory.
974fn native_cache_directory(
975    host: &CacheHost,
976    native: &NativeMbx,
977    executor: &impl CommandExecutor,
978) -> Result<PathBuf> {
979    let command = host.command(
980        vec![
981            native.program.to_string_lossy().into_owned(),
982            "cache".to_owned(),
983            "dir".to_owned(),
984            "--json".to_owned(),
985        ],
986        "read the container host mbx cache directory",
987    );
988    let output = checked(executor.execute(&command)?, &command)?;
989    let report: serde_json::Value =
990        serde_json::from_slice(&output.stdout).context("parse the mbx cache directory report")?;
991    let store = report
992        .get("store")
993        .and_then(serde_json::Value::as_str)
994        .context("the mbx cache directory report has no store path")?;
995    Path::new(store)
996        .parent()
997        .map(Path::to_path_buf)
998        .with_context(|| format!("mbx store path {store:?} has no parent"))
999}
1000
1001const READ_CONFIG_SCRIPT: &str = r#"[ -f "$1" ] || exit 3
1002cat -- "$1""#;
1003
1004/// The host's `~/.config/mbx/config.toml`, which containers receive verbatim
1005/// so their mbx uses the host's own limits. mbx has no command that prints its
1006/// effective configuration, so the file itself is the only accurate source.
1007fn host_config_file(host: &CacheHost, executor: &impl CommandExecutor) -> Result<Option<String>> {
1008    let directory = configuration::host_directory(host, executor)?;
1009    configuration::read_file(host, &directory.join("config.toml"), executor)
1010}
1011
1012/// The `[target] root` a host configuration sets, when it lies outside the
1013/// cache directory and therefore needs its own mount.
1014fn relocated_target_root(config_file: &str, directory: &Path) -> Option<PathBuf> {
1015    let document: toml::Value = toml::from_str(config_file)
1016        .map_err(|error| tracing::warn!("the host mbx configuration is unreadable: {error}"))
1017        .ok()?;
1018    let root = document.get("target")?.get("root")?.as_str()?;
1019    let root = directory.join(root);
1020    (!root.starts_with(directory)).then_some(root)
1021}
1022
1023const NEAREST_ANCESTOR_SCRIPT: &str = r#"d=$1
1024while [ ! -d "$d" ]; do
1025    parent=$(dirname -- "$d")
1026    if [ "$parent" = "$d" ]; then
1027        break
1028    fi
1029    d=$parent
1030done
1031printf '%s' "$d""#;
1032
1033/// The deepest existing directory at or above `directory`. The cache directory
1034/// may not exist yet, and both `df` and the reflink probe need a real one.
1035fn nearest_existing_ancestor(
1036    host: &CacheHost,
1037    directory: &Path,
1038    executor: &impl CommandExecutor,
1039) -> Result<PathBuf> {
1040    let command = host.shell_command(
1041        NEAREST_ANCESTOR_SCRIPT,
1042        LABEL,
1043        [directory.to_string_lossy().into_owned()],
1044        "locate the build cache volume",
1045    );
1046    let output = checked(executor.execute(&command)?, &command)?;
1047    let path = PathBuf::from(String::from_utf8(output.stdout).context("decode cache ancestor")?);
1048    ensure!(
1049        path.is_absolute(),
1050        "build cache volume {} is not absolute",
1051        path.display()
1052    );
1053    Ok(path)
1054}
1055
1056const REFLINK_SCRIPT: &str = r#"dir=$1
1057d=$(mktemp -d "$dir/.mj-reflink.XXXXXX") || exit 1
1058printf x > "$d/a" && cp --reflink=always "$d/a" "$d/b"
1059status=$?
1060rm -rf -- "$d"
1061exit $status"#;
1062
1063/// Whether the cache volume can clone files instead of copying their bytes.
1064/// Reflinks are what make restoring a cached output nearly free, so a host
1065/// without them defaults to running without the cache.
1066fn reflinks_supported(
1067    host: &CacheHost,
1068    volume: &Path,
1069    executor: &impl CommandExecutor,
1070) -> Result<bool> {
1071    let command = host.shell_command(
1072        REFLINK_SCRIPT,
1073        LABEL,
1074        [volume.to_string_lossy().into_owned()],
1075        "probe the build cache volume for reflinks",
1076    );
1077    Ok(executor.execute(&command)?.status == 0)
1078}
1079
1080const CROSS_REFLINK_SCRIPT: &str = r#"src=$1
1081dst=$2
1082s=$(mktemp -d "$src/.mj-reflink.XXXXXX") || exit 1
1083d=$(mktemp -d "$dst/.mj-reflink.XXXXXX") || { rm -rf -- "$s"; exit 1; }
1084printf x > "$s/a" && cp --reflink=always "$s/a" "$d/b"
1085status=$?
1086rm -rf -- "$s" "$d"
1087exit $status"#;
1088
1089/// Whether a cached output can be cloned from the store into the managed
1090/// target root instead of copied. `FICLONE` fails across two mounts even when
1091/// both are the same filesystem, so this asks the pair rather than each side.
1092fn cross_reflinks_supported(
1093    host: &CacheHost,
1094    store: &Path,
1095    target_root: &Path,
1096    executor: &impl CommandExecutor,
1097) -> Result<bool> {
1098    let command = host.shell_command(
1099        CROSS_REFLINK_SCRIPT,
1100        LABEL,
1101        [
1102            store.to_string_lossy().into_owned(),
1103            target_root.to_string_lossy().into_owned(),
1104        ],
1105        "probe the managed target root for reflinks from the build cache",
1106    );
1107    Ok(executor.execute(&command)?.status == 0)
1108}
1109
1110fn create_directory(
1111    host: &CacheHost,
1112    directory: &Path,
1113    executor: &impl CommandExecutor,
1114) -> Result<()> {
1115    let command = host.command(
1116        vec![
1117            "mkdir".to_owned(),
1118            "-p".to_owned(),
1119            "--".to_owned(),
1120            directory.to_string_lossy().into_owned(),
1121        ],
1122        "create the build cache directory",
1123    );
1124    checked(executor.execute(&command)?, &command).map(|_| ())
1125}
1126
1127/// A filesystem mbx cannot use. It refuses NFS outright, and file locks over
1128/// FUSE, virtiofs, and 9p are unreliable, which a shared store depends on.
1129fn unusable_filesystem(
1130    host: &CacheHost,
1131    directory: &Path,
1132    executor: &impl CommandExecutor,
1133) -> Result<Option<&'static str>> {
1134    let filesystems =
1135        targets::probe_filesystem_types(host.ssh(), &[directory.to_path_buf()], executor)?;
1136    let filesystem = filesystems
1137        .first()
1138        .context("the filesystem probe named no filesystem")?;
1139    // `overlay_unsupported_filesystem` already groups virtiofs and 9p with the
1140    // network filesystems. The other reasons it gives are about stacking an
1141    // overlay, which a plain read-write bind mount does not do.
1142    Ok(targets::overlay_unsupported_filesystem(filesystem)
1143        .filter(|reason| matches!(*reason, "network filesystem" | "FUSE filesystem")))
1144}
1145
1146fn checked(output: CommandOutput, command: &CommandSpec) -> Result<CommandOutput> {
1147    if output.status == 0 {
1148        return Ok(output);
1149    }
1150    bail!(
1151        "{} failed with status {}: {}",
1152        command.purpose,
1153        output.status,
1154        String::from_utf8_lossy(&output.stderr).trim()
1155    )
1156}
1157
1158// -- per-session decision -------------------------------------------------
1159
1160/// Whether the primary repository is a Cargo workspace, read from the host
1161/// mirror the clone cache prepared. A repository whose manifest is not at its
1162/// root, and a session whose clone cache was not prepared, run without mbx.
1163pub(super) fn primary_repository_is_rust(
1164    host: &CacheHost,
1165    mirror: &Path,
1166    executor: &impl CommandExecutor,
1167) -> bool {
1168    let command = host.command(
1169        vec![
1170            "git".to_owned(),
1171            "--git-dir".to_owned(),
1172            mirror.to_string_lossy().into_owned(),
1173            "cat-file".to_owned(),
1174            "-e".to_owned(),
1175            "HEAD:Cargo.toml".to_owned(),
1176        ],
1177        "detect a Cargo workspace in the session repository",
1178    );
1179    matches!(executor.execute(&command), Ok(output) if output.status == 0)
1180}
1181
1182/// Decide the build cache for one session and attach its mounts, returning the
1183/// placement to record on the session. Resumes and moves resolve current
1184/// machine policy instead of reviving a saved session budget.
1185pub(super) fn prepare(
1186    target: &targets::TargetTemplate,
1187    session: &mj_core::state::SessionRecord,
1188    bundle: Option<&targets::ProjectBundleSpec>,
1189    clone_cache: Option<&super::git_cache::PreparedCloneCache>,
1190    mounts: &mut Vec<targets::AdditionalMount>,
1191    executor: &impl CommandExecutor,
1192) -> Option<SessionBuildCache> {
1193    // This function prepares container mounts. Budgets always come from the
1194    // machine; a previously recorded budget is never revived on recreation.
1195    // A session at the legacy shared `/workspace` would collide with every
1196    // other legacy session in mbx's path-keyed records.
1197    session.container_workspace.as_ref()?;
1198    let resolved = resolve(target, executor)?;
1199    let host = supported_host(target)?.0;
1200    if session.build_cache.is_none() {
1201        let mirror = clone_cache?.mirror_for(&bundle?.primary)?;
1202        if !primary_repository_is_rust(&host, mirror, executor) {
1203            return None;
1204        }
1205    }
1206    match sync_current_mbx_binary(&host, &resolved.directory, executor) {
1207        Ok(CachedMbxSync::Ready(_)) => {}
1208        Ok(CachedMbxSync::Unavailable(reason)) => {
1209            tracing::warn!(
1210                host = host.key(),
1211                "sessions run without the shared build cache: {reason}"
1212            );
1213            executor.notify_notice(&format!(
1214                "The shared Rust build cache is unavailable: {reason}. The session will start without it."
1215            ));
1216            return None;
1217        }
1218        Err(error) => {
1219            tracing::warn!(
1220                host = host.key(),
1221                "the shared mbx binary could not be synchronized; the session runs without the build cache: {error:#}"
1222            );
1223            executor.notify_notice(&format!(
1224                "The shared Rust build cache is unavailable: {error:#}. The session will start without it."
1225            ));
1226            return None;
1227        }
1228    }
1229    let build_cache = SessionBuildCache {
1230        host: host.key(),
1231        directory: resolved.directory,
1232        max_size: None,
1233        target_root: resolved.target_root,
1234    };
1235    attach_mounts(&build_cache, mounts).then_some(build_cache)
1236}
1237
1238/// Mount the cache, and a relocated target root, read-write at the same
1239/// absolute paths the host uses. An attached directory that already covers one
1240/// of those paths wins, and the session runs without the cache.
1241fn attach_mounts(
1242    build_cache: &SessionBuildCache,
1243    mounts: &mut Vec<targets::AdditionalMount>,
1244) -> bool {
1245    let mut wanted = vec![build_cache.directory.clone()];
1246    wanted.extend(build_cache.target_root.iter().cloned());
1247    for destination in &wanted {
1248        if mounts.iter().any(|mount| {
1249            mount.destination.starts_with(destination)
1250                || destination.starts_with(&mount.destination)
1251        }) {
1252            tracing::warn!(
1253                destination = %destination.display(),
1254                "an attached directory overlaps the build cache, so this session runs without it"
1255            );
1256            return false;
1257        }
1258    }
1259    for directory in std::iter::once(&build_cache.directory).chain(build_cache.target_root.iter()) {
1260        mounts.push(targets::AdditionalMount {
1261            source: directory.clone(),
1262            destination: directory.clone(),
1263            access: targets::MountAccess::Rw,
1264        });
1265    }
1266    true
1267}
1268
1269/// Read the configuration on the host that actually owns this container.
1270/// The named template may have been removed or reassigned since creation.
1271pub(super) fn host_for_locator(target: &targets::TargetLocator) -> Option<CacheHost> {
1272    match target {
1273        targets::TargetLocator::LocalPodman { .. } | targets::TargetLocator::LocalDocker { .. } => {
1274            Some(CacheHost::Local)
1275        }
1276        targets::TargetLocator::SshPodman { ssh, .. }
1277        | targets::TargetLocator::SshDocker { ssh, .. } => Some(CacheHost::Ssh(ssh.clone())),
1278        _ => None,
1279    }
1280}
1281
1282/// Refresh a new-scheme session's copy from the host that owns its container.
1283pub(super) fn sync_mbx_binary_for_container(
1284    target: &targets::TargetLocator,
1285    directory: &Path,
1286    executor: &impl CommandExecutor,
1287) -> Result<CachedMbxSync> {
1288    let host = host_for_locator(target).context("build cache has no container host")?;
1289    sync_current_mbx_binary(&host, directory, executor)
1290}
1291
1292#[cfg(test)]
1293mod tests {
1294    use super::*;
1295    use crate::targets::{ContainerTemplate, SshTarget, TargetTemplate};
1296    use mj_core::config::ImagePullPolicy;
1297    use std::sync::Mutex;
1298
1299    /// The resolution cache is process-wide, so tests that exercise it run one
1300    /// at a time and start from an empty cache.
1301    static ISOLATED: Mutex<()> = Mutex::new(());
1302
1303    fn isolated() -> std::sync::MutexGuard<'static, ()> {
1304        let guard = ISOLATED.lock().unwrap_or_else(|error| error.into_inner());
1305        RESOLUTIONS.lock().expect("mbx resolutions").clear();
1306        APPLICATIONS.lock().expect("mbx applications").clear();
1307        guard
1308    }
1309
1310    /// Answers canned commands by a substring of their joined argument list.
1311    #[derive(Default)]
1312    struct ProbeExecutor {
1313        answers: Vec<(&'static str, i32, String)>,
1314        seen: Mutex<Vec<String>>,
1315    }
1316
1317    impl ProbeExecutor {
1318        fn new(answers: &[(&'static str, i32, &str)]) -> Self {
1319            Self {
1320                answers: answers
1321                    .iter()
1322                    .map(|(needle, status, stdout)| (*needle, *status, (*stdout).to_owned()))
1323                    .chain(std::iter::once(("uname -sm", 0, "Linux x86_64\n".into())))
1324                    .collect(),
1325                seen: Mutex::new(Vec::new()),
1326            }
1327        }
1328
1329        fn ran(&self) -> Vec<String> {
1330            self.seen.lock().unwrap().clone()
1331        }
1332    }
1333
1334    impl CommandExecutor for ProbeExecutor {
1335        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1336            let line = format!("{} {}", command.program, command.args.join(" "));
1337            self.seen.lock().unwrap().push(line.clone());
1338            // Undo the quoting added by join_remote_command for fixture matching.
1339            let searchable = if command.program == "ssh" {
1340                line.replace("'\\''", "'").replace("' '", " ")
1341            } else {
1342                line.clone()
1343            };
1344            if searchable.contains("hel-mbx-profile") {
1345                return Ok(CommandOutput {
1346                    status: 0,
1347                    stdout: b"preview\n~/.profile\nposix\n/home/jonathan/.local/share/mbx/bin"
1348                        .to_vec(),
1349                    stderr: Vec::new(),
1350                });
1351            }
1352            for (needle, status, stdout) in &self.answers {
1353                if searchable.contains(needle) {
1354                    return Ok(CommandOutput {
1355                        status: *status,
1356                        stdout: stdout.clone().into_bytes(),
1357                        stderr: Vec::new(),
1358                    });
1359                }
1360            }
1361            Ok(CommandOutput {
1362                status: 127,
1363                stdout: Vec::new(),
1364                stderr: format!("no canned answer for {line}").into_bytes(),
1365            })
1366        }
1367    }
1368
1369    fn container(build_cache: Option<TargetBuildCache>) -> ContainerTemplate {
1370        ContainerTemplate {
1371            image: "example/image:latest".into(),
1372            pull_policy: ImagePullPolicy::Missing,
1373            extra_run_args: Vec::new(),
1374            workspace_storage: Default::default(),
1375            build_cache,
1376        }
1377    }
1378
1379    fn podman(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
1380        TargetTemplate::LocalPodman(container(build_cache))
1381    }
1382
1383    fn docker(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
1384        TargetTemplate::LocalDocker(container(build_cache))
1385    }
1386
1387    /// The settings draft's view of this machine with blank build cache
1388    /// fields.
1389    fn configured_local_machine() -> mj_core::config::Machine {
1390        serde_json::from_value(serde_json::json!({"kind": "local"})).unwrap()
1391    }
1392
1393    /// A native mbx's `--version` answer at the release containers run.
1394    fn current_native_mbx() -> String {
1395        format!("/usr/local/bin/mbx\nmbx {MBX_VERSION}")
1396    }
1397
1398    fn native_host() -> Vec<(&'static str, i32, &'static str)> {
1399        vec![
1400            ("$resolved\" --version", 0, "/usr/local/bin/mbx\nmbx 1.22.0"),
1401            (
1402                "mbx cache dir --json",
1403                0,
1404                r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1405            ),
1406            ("XDG_CONFIG_HOME", 0, "/home/dev/.config/mbx"),
1407            ("[ -f \"$1\" ]", 3, ""),
1408            ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1409            ("mj-reflink", 0, ""),
1410            ("mkdir -p", 0, ""),
1411            ("stat -f -c %T", 0, "xfs"),
1412            ("source=$1 destination=$2 expected=$3", 0, "synced\n"),
1413        ]
1414    }
1415
1416    fn absent_host() -> Vec<(&'static str, i32, &'static str)> {
1417        vec![("$resolved\" --version", 1, "")]
1418    }
1419
1420    #[test]
1421    fn darwin_hosts_skip_cache_inspection_provisioning_and_reconciliation() {
1422        let _isolated = isolated();
1423        for remote in [false, true] {
1424            for enabled in [None, Some(true)] {
1425                let settings = TargetBuildCache {
1426                    enabled,
1427                    ..Default::default()
1428                };
1429                let machine: mj_core::config::Machine = if remote {
1430                    serde_json::from_value(serde_json::json!({
1431                        "kind": "ssh", "host": "mac.test", "user": "builder", "build_cache": settings,
1432                    }))
1433                    .unwrap()
1434                } else {
1435                    mj_core::config::Machine::Local {
1436                        build_cache: Some(settings.clone()),
1437                    }
1438                };
1439                let target = if remote {
1440                    TargetTemplate::SshPodman {
1441                        ssh: SshTarget {
1442                            destination: "builder@mac.test".into(),
1443                            ssh_args: vec![],
1444                        },
1445                        container: container(Some(settings)),
1446                    }
1447                } else {
1448                    podman(Some(settings))
1449                };
1450                // An installed native mbx must not enable Mjolnir's integration.
1451                let executor = ProbeExecutor::new(&[
1452                    ("uname -sm", 0, "Darwin arm64\n"),
1453                    ("$resolved\" --version", 0, "mbx\nmbx 1.16.0"),
1454                ]);
1455                let preview = preview_build_cache(&machine, &executor).unwrap().unwrap();
1456                assert_eq!(
1457                    preview.off_reason,
1458                    Some(BuildCacheOff::Unavailable(UNSUPPORTED_HOST.into()))
1459                );
1460                assert!(preview.directory.is_none());
1461                assert!(resolve(&target, &executor).is_none());
1462                apply_machine_build_cache(&machine, &[PathBuf::from("/existing/cache")], &executor)
1463                    .unwrap();
1464                let commands = executor.ran();
1465                assert!(!commands.is_empty());
1466                assert!(
1467                    commands
1468                        .iter()
1469                        .all(|command| command.contains("uname") && command.contains("-sm")),
1470                    "{commands:?}"
1471                );
1472                assert!(
1473                    commands
1474                        .iter()
1475                        .all(|command| command.starts_with(if remote { "ssh " } else { "uname " }))
1476                );
1477            }
1478        }
1479    }
1480
1481    #[test]
1482    fn linux_ssh_cache_remains_available_on_any_controller_platform() {
1483        let _isolated = isolated();
1484        let executor = ProbeExecutor::new(&native_host());
1485        let target = TargetTemplate::SshDocker {
1486            ssh: SshTarget {
1487                destination: "builder@linux.test".into(),
1488                ssh_args: vec![],
1489            },
1490            container: container(None),
1491        };
1492        let cache = resolve(&target, &executor).unwrap();
1493        assert_eq!(cache.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1494        assert_eq!(cache.previous_config, cache.config_file);
1495        assert!(
1496            executor.ran().iter().all(
1497                |command| command.starts_with("ssh ") && command.contains("builder@linux.test")
1498            )
1499        );
1500    }
1501
1502    #[test]
1503    fn reconciliation_refreshes_active_mounts_when_cache_policy_is_disabled() {
1504        let _isolated = isolated();
1505        let machine = mj_core::config::Machine::Local {
1506            build_cache: Some(TargetBuildCache {
1507                enabled: Some(false),
1508                ..Default::default()
1509            }),
1510        };
1511        let executor = ProbeExecutor::new(&native_host());
1512        let mounted = PathBuf::from("/existing/cache");
1513
1514        apply_machine_build_cache(&machine, std::slice::from_ref(&mounted), &executor).unwrap();
1515
1516        assert!(executor.ran().iter().any(|command| {
1517            command.contains("source=$1 destination=$2 expected=$3")
1518                && command.contains("/existing/cache/.mjolnir/bin/mbx")
1519        }));
1520    }
1521
1522    #[test]
1523    fn recorded_darwin_cache_fails_explicitly_without_writing() {
1524        let executor = ProbeExecutor::new(&[("uname -sm", 0, "Darwin x86_64")]);
1525        let recorded = SessionBuildCache {
1526            host: "local".into(),
1527            directory: PathBuf::from("/existing/cache"),
1528            max_size: None,
1529            target_root: None,
1530        };
1531        let backend = targets::TargetLocator::LocalPodman {
1532            container_id: "saved-container".into(),
1533            workspace_storage: Default::default(),
1534            borrowed_from: None,
1535        };
1536        let error =
1537            prepare_session_configuration(&Config::default(), &backend, &recorded, &executor)
1538                .unwrap_err();
1539        assert!(format!("{error:#}").contains(UNSUPPORTED_HOST));
1540        assert_eq!(executor.ran(), ["uname -sm"]);
1541    }
1542
1543    #[test]
1544    fn failed_platform_probe_does_not_attempt_cache_operations() {
1545        let executor = ProbeExecutor::new(&[("uname -sm", 1, "")]);
1546        assert!(inspect_host(&CacheHost::Local, &TargetBuildCache::default(), &executor).is_err());
1547        assert_eq!(executor.ran(), ["uname -sm"]);
1548    }
1549
1550    #[test]
1551    fn installed_worker_reads_cache_configuration_from_its_recorded_host() {
1552        let executor = ProbeExecutor::new(&[
1553            ("XDG_CONFIG_HOME", 0, "/home/builder/.config/mbx"),
1554            ("$HOME", 0, "/home/builder"),
1555            ("[ -f \"$1\" ]", 0, "[gc]\nmax_total_size = '50GB'\n"),
1556        ]);
1557        let target = targets::TargetLocator::SshPodman {
1558            ssh: SshTarget {
1559                destination: "builder@recorded-cache.test".into(),
1560                ssh_args: vec![],
1561            },
1562            container_id: "saved-container".into(),
1563            workspace_storage: Default::default(),
1564            borrowed_from: None,
1565        };
1566        let config = host_config_file(&host_for_locator(&target).unwrap(), &executor)
1567            .unwrap()
1568            .unwrap();
1569        assert!(config.contains("50GB"));
1570        assert!(
1571            executor
1572                .seen
1573                .lock()
1574                .unwrap()
1575                .iter()
1576                .all(|command| command.contains("builder@recorded-cache.test"))
1577        );
1578    }
1579
1580    #[test]
1581    fn a_native_mbx_supplies_the_cache_directory_and_its_own_limits() {
1582        let _isolated = isolated();
1583        let executor = ProbeExecutor::new(&[
1584            ("$resolved\" --version", 0, current_native_mbx().as_str()),
1585            (
1586                "mbx cache dir --json",
1587                0,
1588                r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1589            ),
1590            (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1591            (
1592                "[ -f \"$1\" ]",
1593                0,
1594                "cache_dir = \"/mnt/fast/mbx-cache\"\n[gc]\nmax_size = \"500GiB\"\n",
1595            ),
1596            ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1597            ("mj-reflink", 0, ""),
1598            ("mkdir -p", 0, ""),
1599            ("stat -f -c %T", 0, "xfs"),
1600        ]);
1601        let resolved = resolve(&podman(None), &executor).unwrap();
1602        assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1603        // The host's own configuration file carries the budget.
1604        assert_eq!(
1605            configuration::configured_limit(
1606                resolved.config_file.as_deref(),
1607                "gc",
1608                "max_total_size"
1609            )
1610            .unwrap(),
1611            None
1612        );
1613        assert_eq!(resolved.target_root, None);
1614        assert!(resolved.config_file.unwrap().contains("500GiB"));
1615        assert!(
1616            !executor
1617                .ran()
1618                .iter()
1619                .any(|line| line.contains("apply machine")),
1620            "a host configuration is never rewritten"
1621        );
1622    }
1623    // Hard-won: 24f3d72c: settings refresh left sessions using a stale failed host inspection
1624    #[test]
1625    fn looking_at_the_settings_page_lets_the_next_session_see_a_repaired_host() {
1626        let _isolated = isolated();
1627        let broken = ProbeExecutor::new(
1628            &native_host()
1629                .into_iter()
1630                .map(|(needle, status, stdout)| match needle {
1631                    "mj-reflink" => (needle, 1, stdout),
1632                    _ => (needle, status, stdout),
1633                })
1634                .collect::<Vec<_>>(),
1635        );
1636        assert!(
1637            resolve(&podman(None), &broken).is_none(),
1638            "a volume that cannot clone runs without the cache"
1639        );
1640
1641        // The host is repaired, and the user opens the machine's build cache
1642        // page to check.
1643        let repaired = ProbeExecutor::new(&native_host());
1644        let preview = preview_build_cache(&configured_local_machine(), &repaired)
1645            .expect("the host answers")
1646            .expect("a local machine can hold a cache");
1647        assert_eq!(preview.off_reason, None);
1648
1649        assert!(
1650            resolve(&podman(None), &repaired).is_some(),
1651            "the next session asks the repaired host again instead of reusing the old verdict"
1652        );
1653    }
1654
1655    #[test]
1656    fn a_target_root_that_cannot_be_cloned_into_still_gets_the_cache() {
1657        let _isolated = isolated();
1658        let executor = ProbeExecutor::new(&[
1659            ("$resolved\" --version", 0, current_native_mbx().as_str()),
1660            (
1661                "mbx cache dir --json",
1662                0,
1663                r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1664            ),
1665            (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1666            (
1667                "[ -f \"$1\" ]",
1668                0,
1669                "[target]\nroot = \"/mnt/slow/mbx-targets\"\n",
1670            ),
1671            ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1672            // Cloning from the store into the relocated root fails; cloning
1673            // within the store still works.
1674            ("src=$1", 1, ""),
1675            ("mj-reflink", 0, ""),
1676            ("mkdir -p", 0, ""),
1677            ("stat -f -c %T", 0, "xfs"),
1678        ]);
1679        let resolved = resolve(&podman(None), &executor)
1680            .expect("a target root that copies instead of cloning is slower, not unusable");
1681        assert_eq!(
1682            resolved.target_root,
1683            Some(PathBuf::from("/mnt/slow/mbx-targets"))
1684        );
1685        assert!(
1686            executor.ran().iter().any(|line| line.contains("src=$1")),
1687            "the store and the target root are probed as a pair: {:?}",
1688            executor.ran()
1689        );
1690    }
1691
1692    #[test]
1693    fn a_target_root_inside_the_cache_directory_needs_no_second_mount() {
1694        assert_eq!(
1695            relocated_target_root("[target]\nroot = \"targets\"\n", Path::new("/cache")),
1696            None
1697        );
1698        assert_eq!(
1699            relocated_target_root("[target]\nroot = \"/cache/targets\"\n", Path::new("/cache")),
1700            None
1701        );
1702    }
1703
1704    // Hard-won: ecab42fb: non-login SSH PATH hid cargo-installed mbx and selected the wrong cache store
1705    #[test]
1706    fn a_cargo_installed_mbx_off_the_path_is_queried_where_it_was_found() {
1707        let _isolated = isolated();
1708        let found = format!("/home/dev/.cargo/bin/mbx\nmbx {MBX_VERSION}");
1709        let mut answers: Vec<(&'static str, i32, &str)> = native_host();
1710        answers.retain(|(needle, _, _)| *needle != "$resolved\" --version");
1711        answers.push(("$resolved\" --version", 0, found.as_str()));
1712        answers.push((
1713            "/home/dev/.cargo/bin/mbx cache dir --json",
1714            0,
1715            r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1716        ));
1717        let executor = ProbeExecutor::new(&answers);
1718        let resolved = resolve(&podman(None), &executor).unwrap();
1719        assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1720        assert_eq!(
1721            resolved.native_mbx.program,
1722            PathBuf::from("/home/dev/.cargo/bin/mbx")
1723        );
1724    }
1725
1726    #[test]
1727    fn an_older_native_mbx_must_not_share_the_store() {
1728        let _isolated = isolated();
1729        let executor = ProbeExecutor::new(&[
1730            (
1731                "$resolved\" --version",
1732                0,
1733                "/home/jonathan/.local/bin/mbx\nmbx 1.15.0",
1734            ),
1735            ("hel-mbx-home", 0, "/home/jonathan"),
1736        ]);
1737        assert!(resolve(&podman(None), &executor).is_none());
1738        let preview = preview_build_cache(&configured_local_machine(), &executor)
1739            .unwrap()
1740            .unwrap();
1741        assert!(matches!(
1742            preview.off_reason,
1743            Some(BuildCacheOff::Unavailable(reason))
1744                if reason.contains("1.15.0") && reason.contains("Settings › Setup › Machines")
1745        ));
1746    }
1747
1748    #[test]
1749    fn a_host_without_mbx_has_no_shared_cache_and_preview_shows_setup_guidance() {
1750        let _isolated = isolated();
1751        let mut answers = absent_host();
1752        answers.push(("hel-mbx-home", 0, "/home/jonathan"));
1753        let executor = ProbeExecutor::new(&answers);
1754        assert!(resolve(&podman(None), &executor).is_none());
1755        let preview = preview_build_cache(&configured_local_machine(), &executor)
1756            .unwrap()
1757            .unwrap();
1758        assert_eq!(preview.native_mbx, None);
1759        assert_eq!(preview.directory, None);
1760        assert!(matches!(
1761            preview.off_reason,
1762            Some(BuildCacheOff::Unavailable(reason))
1763                if reason.contains("Settings › Setup › Machines")
1764        ));
1765        // The shared profile script contains a mkdir branch, but preview must
1766        // not invoke a separate host mkdir command for the cache directory.
1767        assert!(!executor.ran().iter().any(|line| line.starts_with("mkdir ")));
1768    }
1769
1770    #[test]
1771    fn a_native_installation_owns_the_budget_despite_saved_mj_overrides() {
1772        let _isolated = isolated();
1773        let native = current_native_mbx();
1774        let mut answers = vec![
1775            ("$resolved\" --version", 0, native.as_str()),
1776            (
1777                "mbx cache dir --json",
1778                0,
1779                r#"{"store":"/native/cache/actions"}"#,
1780            ),
1781            (
1782                "[ -f \"$1\" ]",
1783                0,
1784                "[gc]\nmax_total_size = '400GiB'\n[target]\nmax_size = 'none'\n",
1785            ),
1786        ];
1787        answers.extend(native_host());
1788        let executor = ProbeExecutor::new(&answers);
1789        let settings = TargetBuildCache {
1790            directory: Some("/ignored".into()),
1791            max_total_size: Some("1GB".into()),
1792            ..Default::default()
1793        };
1794        let inspection = inspect_host(&CacheHost::Local, &settings, &executor).unwrap();
1795        assert!(inspection.preview.user_managed);
1796        assert_eq!(inspection.preview.directory, Some("/native/cache".into()));
1797        assert_eq!(
1798            inspection.preview.max_total_size,
1799            Some(BuildCacheLimit::HostConfiguration(Some("400GiB".into())))
1800        );
1801        assert!(
1802            !executor
1803                .ran()
1804                .iter()
1805                .any(|command| command.contains(".mj-apply.lock"))
1806        );
1807    }
1808
1809    /// Turning the cache on cannot override the host: without reflinks a
1810    /// restore would copy every byte, so sessions still run without it.
1811    #[test]
1812    fn an_enabled_setting_does_not_survive_a_volume_without_reflinks() {
1813        let _isolated = isolated();
1814        let mut answers = native_host();
1815        answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1816        answers.push(("mj-reflink", 1, ""));
1817        let executor = ProbeExecutor::new(&answers);
1818        assert_eq!(
1819            resolve(
1820                &podman(Some(TargetBuildCache {
1821                    enabled: Some(true),
1822                    directory: None,
1823                    max_total_size: None,
1824                    scheduler: Default::default(),
1825                })),
1826                &executor,
1827            ),
1828            None
1829        );
1830        // An unset target preference is overridden by the same host capability.
1831        assert_eq!(resolve(&podman(None), &executor), None);
1832    }
1833
1834    #[test]
1835    fn a_network_filesystem_runs_without_the_cache() {
1836        let _isolated = isolated();
1837        let mut answers = native_host();
1838        answers.retain(|(needle, _, _)| *needle != "stat -f -c %T");
1839        answers.push(("stat -f -c %T", 0, "nfs4"));
1840        let executor = ProbeExecutor::new(&answers);
1841        assert_eq!(resolve(&podman(None), &executor), None);
1842    }
1843
1844    #[test]
1845    fn local_podman_and_local_docker_inspect_one_machine_once() {
1846        let _isolated = isolated();
1847        let executor = ProbeExecutor::new(&native_host());
1848        let first = resolve(&podman(None), &executor).unwrap();
1849        let ran = executor.ran().len();
1850        assert!(ran > 0, "the first resolve inspects the host");
1851        let second = resolve(&docker(None), &executor).unwrap();
1852        assert_eq!(
1853            first, second,
1854            "both engines on this machine share one cache"
1855        );
1856        // The inspection is memoized; creating the directory is not, because a
1857        // remembered inspection says what the host looked like, not that the
1858        // directory still exists.
1859        let added = executor.ran()[ran..].to_vec();
1860        assert_eq!(
1861            added.len(),
1862            1,
1863            "the second runtime is answered from the machine's recorded inspection: {added:?}"
1864        );
1865        assert!(
1866            added[0].contains("mkdir -p"),
1867            "the one repeated command creates the directory: {added:?}"
1868        );
1869    }
1870
1871    #[test]
1872    fn the_preview_reports_what_the_cache_has_already_done() {
1873        let _isolated = isolated();
1874        // Ahead of the configuration read, which tests the same `[ -f ]`.
1875        let mut answers = vec![(
1876            "tally.json",
1877            0,
1878            r#"{"version":1,"since_secs":1789824719,"builds":155,"cached_compilations":12050,"avoided_compiler_ns":6004997818721,"reflinked_bytes":47612059386}"#,
1879        )];
1880        answers.extend(native_host());
1881        let executor = ProbeExecutor::new(&answers);
1882        let preview = preview_build_cache(&configured_local_machine(), &executor)
1883            .expect("the host answers")
1884            .expect("a local machine can hold a cache");
1885        assert_eq!(
1886            preview.stats,
1887            Some(mj_core::state::BuildCacheStats {
1888                builds: 155,
1889                cached_compilations: 12050,
1890                avoided_compiler_ns: 6_004_997_818_721,
1891                reflinked_bytes: 47_612_059_386,
1892            })
1893        );
1894    }
1895    #[test]
1896    fn a_cache_nothing_has_used_yet_reports_no_totals() {
1897        let _isolated = isolated();
1898        // `native_host` answers every `[ -f ]` with 3: no configuration file
1899        // and no tally beside the store.
1900        let executor = ProbeExecutor::new(&native_host());
1901        let preview = preview_build_cache(&configured_local_machine(), &executor)
1902            .expect("the host answers")
1903            .expect("a local machine can hold a cache");
1904        assert_eq!(preview.stats, None);
1905    }
1906    fn bundle() -> targets::ProjectBundleSpec {
1907        targets::ProjectBundleSpec {
1908            primary: "main".into(),
1909            repositories: vec![targets::RepositorySpec {
1910                url: Some("https://github.com/example/main.git".into()),
1911                push_urls: Vec::new(),
1912                destination: "main".into(),
1913                git_ref: None,
1914                reference: None,
1915            }],
1916        }
1917    }
1918
1919    fn clone_cache() -> super::super::git_cache::PreparedCloneCache {
1920        super::super::git_cache::PreparedCloneCache::from_mirrors(
1921            [(
1922                "main".to_owned(),
1923                PathBuf::from("/home/dev/mirror/repo.git"),
1924            )]
1925            .into_iter()
1926            .collect(),
1927        )
1928    }
1929
1930    fn session(container_workspace: Option<&str>) -> mj_core::state::SessionRecord {
1931        let mut record = crate::controller::test_support::checkpoint_test_session("session-1");
1932        record.container_workspace = container_workspace.map(PathBuf::from);
1933        record
1934    }
1935
1936    #[test]
1937    fn a_rust_session_mounts_the_native_cache_and_records_its_synchronized_binary() {
1938        let _isolated = isolated();
1939        let mut answers = native_host();
1940        answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1941        let executor = ProbeExecutor::new(&answers);
1942        let mut mounts = Vec::new();
1943        let build_cache = prepare(
1944            &podman(None),
1945            &session(Some("/workspace/session-1")),
1946            Some(&bundle()),
1947            Some(&clone_cache()),
1948            &mut mounts,
1949            &executor,
1950        )
1951        .expect("a Rust session uses the build cache");
1952        assert_eq!(build_cache.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1953        assert_eq!(
1954            cache_binary_path(&build_cache.directory),
1955            PathBuf::from("/mnt/fast/mbx-cache/.mjolnir/bin/mbx")
1956        );
1957        assert_eq!(
1958            mounts,
1959            vec![targets::AdditionalMount {
1960                source: PathBuf::from("/mnt/fast/mbx-cache"),
1961                destination: PathBuf::from("/mnt/fast/mbx-cache"),
1962                access: targets::MountAccess::Rw,
1963            }]
1964        );
1965    }
1966
1967    #[test]
1968    fn a_session_at_the_legacy_shared_workspace_runs_without_the_cache() {
1969        let _isolated = isolated();
1970        let mut answers = native_host();
1971        answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
1972        let executor = ProbeExecutor::new(&answers);
1973        let mut mounts = Vec::new();
1974        assert_eq!(
1975            prepare(
1976                &podman(None),
1977                &session(None),
1978                Some(&bundle()),
1979                Some(&clone_cache()),
1980                &mut mounts,
1981                &executor,
1982            ),
1983            None
1984        );
1985        assert!(executor.ran().is_empty());
1986    }
1987
1988    #[test]
1989    fn a_resumed_session_uses_current_machine_policy_instead_of_its_saved_budget() {
1990        let _isolated = isolated();
1991        let executor = ProbeExecutor::new(&native_host());
1992        let mut record = session(Some("/workspace/session-1"));
1993        record.build_cache = Some(SessionBuildCache {
1994            host: "local".into(),
1995            directory: PathBuf::from("/mnt/fast/mbx-cache"),
1996            max_size: Some("1GB".into()),
1997            target_root: None,
1998        });
1999        let mut mounts = Vec::new();
2000        let build_cache =
2001            prepare(&podman(None), &record, None, None, &mut mounts, &executor).unwrap();
2002        assert_eq!(build_cache.max_size, None);
2003        assert_eq!(build_cache.directory, PathBuf::from("/mnt/fast/mbx-cache"));
2004        assert_eq!(mounts.len(), 1);
2005        assert!(
2006            executor
2007                .ran()
2008                .iter()
2009                .any(|line| line.contains(".mj-apply.lock"))
2010        );
2011    }
2012
2013    #[test]
2014    fn a_session_moved_to_another_host_resolves_its_build_cache_again() {
2015        let _isolated = isolated();
2016        let mut answers = native_host();
2017        answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2018        let executor = ProbeExecutor::new(&answers);
2019        let mut record = session(Some("/workspace/session-1"));
2020        record.build_cache = Some(SessionBuildCache {
2021            // The host the session was provisioned on, which the target below
2022            // is not.
2023            host: "ssh:dev@example.test".into(),
2024            directory: PathBuf::from("/mnt/fast/mbx-cache"),
2025            max_size: None,
2026            target_root: Some(PathBuf::from("/mnt/fast/mbx-targets")),
2027        });
2028        let mut mounts = Vec::new();
2029
2030        let build_cache = prepare(
2031            &podman(None),
2032            &record,
2033            Some(&bundle()),
2034            Some(&clone_cache()),
2035            &mut mounts,
2036            &executor,
2037        )
2038        .expect("the destination host qualifies on its own");
2039
2040        assert_eq!(build_cache.host, "local");
2041        assert_eq!(build_cache.directory, PathBuf::from("/mnt/fast/mbx-cache"));
2042        assert_eq!(build_cache.target_root, None);
2043        assert_eq!(
2044            mounts
2045                .iter()
2046                .map(|mount| mount.destination.clone())
2047                .collect::<Vec<_>>(),
2048            vec![PathBuf::from("/mnt/fast/mbx-cache")]
2049        );
2050        assert!(
2051            executor
2052                .ran()
2053                .iter()
2054                .any(|line| line.contains("mj-reflink"))
2055        );
2056    }
2057
2058    #[test]
2059    fn an_attached_directory_over_the_cache_wins() {
2060        let build_cache = SessionBuildCache {
2061            host: "local-podman".into(),
2062            directory: PathBuf::from("/mnt/fast/mbx-cache"),
2063            max_size: None,
2064            target_root: None,
2065        };
2066        let mut mounts = vec![targets::AdditionalMount {
2067            source: PathBuf::from("/elsewhere"),
2068            destination: PathBuf::from("/mnt/fast/mbx-cache/actions"),
2069            access: targets::MountAccess::Ro,
2070        }];
2071        assert!(!attach_mounts(&build_cache, &mut mounts));
2072        assert_eq!(mounts.len(), 1);
2073    }
2074
2075    #[test]
2076    fn versions_compare_by_release_order() {
2077        let native = |version: &str| NativeMbx {
2078            program: "/usr/local/bin/mbx".into(),
2079            version: version.into(),
2080        };
2081        assert!(matches!(
2082            classify_native_mbx(Some(native(MBX_VERSION))),
2083            NativeMbxStatus::Compatible(_)
2084        ));
2085        assert!(matches!(
2086            classify_native_mbx(Some(native("1.23.0"))),
2087            NativeMbxStatus::Compatible(_)
2088        ));
2089        assert!(matches!(
2090            classify_native_mbx(Some(native("1.15.0"))),
2091            NativeMbxStatus::TooOld(_)
2092        ));
2093        assert!(matches!(
2094            classify_native_mbx(Some(native("not-a-version"))),
2095            NativeMbxStatus::Unknown { .. }
2096        ));
2097        assert!(matches!(classify_native_mbx(None), NativeMbxStatus::Absent));
2098    }
2099
2100    #[test]
2101    fn available_bytes_reads_the_df_available_column() {
2102        assert_eq!(
2103            available_bytes(
2104                "Filesystem 1K-blocks Used Available Capacity Mounted on\n\
2105                 /dev/sda1 1000 400 600 40% /\n"
2106            ),
2107            Some(600)
2108        );
2109        assert_eq!(available_bytes("Filesystem 1K-blocks\n"), None);
2110    }
2111
2112    #[cfg(target_os = "linux")]
2113    #[test]
2114    fn native_probe_prefers_path_then_user_bins_and_canonicalizes_symlinks() {
2115        use std::os::unix::fs::{PermissionsExt, symlink};
2116
2117        let root = tempfile::tempdir().unwrap();
2118        let path_bin = root.path().join("path-bin");
2119        let home = root.path().join("home");
2120        let local_bin = home.join(".local/bin");
2121        let cargo_bin = home.join(".cargo/bin");
2122        let real_bin = root.path().join("real");
2123        for directory in [&path_bin, &local_bin, &cargo_bin, &real_bin] {
2124            std::fs::create_dir_all(directory).unwrap();
2125        }
2126        let install = |path: &Path, version: &str| {
2127            std::fs::write(path, format!("#!/bin/sh\nprintf 'mbx {version}\\n'\n")).unwrap();
2128            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2129        };
2130        let real_mbx = real_bin.join("mbx-real");
2131        install(&real_mbx, "1.30.0");
2132        symlink(&real_mbx, path_bin.join("mbx")).unwrap();
2133        install(&local_bin.join("mbx"), "1.31.0");
2134        install(&cargo_bin.join("mbx"), "1.32.0");
2135        let readlink = std::env::split_paths(&std::env::var_os("PATH").unwrap())
2136            .map(|directory| directory.join("readlink"))
2137            .find(|candidate| candidate.is_file())
2138            .expect("readlink utility is available");
2139        symlink(readlink, path_bin.join("readlink")).unwrap();
2140
2141        let mut command = CommandSpec::new("/bin/sh", ["-c", NATIVE_VERSION_SCRIPT])
2142            .purpose("test native mbx path resolution");
2143        command.clear_env = true;
2144        command
2145            .env
2146            .insert("PATH".into(), path_bin.display().to_string());
2147        command
2148            .env
2149            .insert("HOME".into(), home.display().to_string());
2150        let executor = targets::ProcessExecutor;
2151
2152        let output = executor.execute(&command).unwrap();
2153        assert_eq!(output.status, 0);
2154        assert_eq!(
2155            parse_native_probe_output(&output.stdout).unwrap(),
2156            NativeMbx {
2157                program: real_mbx.clone(),
2158                version: "1.30.0".into(),
2159            }
2160        );
2161
2162        std::fs::remove_file(path_bin.join("mbx")).unwrap();
2163        let output = executor.execute(&command).unwrap();
2164        assert_eq!(output.status, 0);
2165        assert_eq!(
2166            parse_native_probe_output(&output.stdout).unwrap().program,
2167            local_bin.join("mbx")
2168        );
2169
2170        std::fs::remove_file(local_bin.join("mbx")).unwrap();
2171        let output = executor.execute(&command).unwrap();
2172        assert_eq!(output.status, 0);
2173        assert_eq!(
2174            parse_native_probe_output(&output.stdout).unwrap().program,
2175            cargo_bin.join("mbx")
2176        );
2177    }
2178
2179    #[cfg(target_os = "linux")]
2180    #[test]
2181    fn cache_copy_refresh_is_atomic_and_skips_unchanged_binaries() {
2182        use std::os::unix::fs::{MetadataExt, PermissionsExt};
2183
2184        let root = tempfile::tempdir().unwrap();
2185        let source = root.path().join("native mbx");
2186        let native_bin = root.path().join("native-bin");
2187        let home = root.path().join("home");
2188        let cache = root.path().join("cache with spaces");
2189        std::fs::create_dir_all(&native_bin).unwrap();
2190        std::fs::create_dir_all(&home).unwrap();
2191        let install = |version: &str| {
2192            std::fs::write(&source, format!("#!/bin/sh\nprintf 'mbx {version}\\n'\n")).unwrap();
2193            std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o755)).unwrap();
2194        };
2195        let native = |version: &str| NativeMbx {
2196            program: source.clone(),
2197            version: version.to_owned(),
2198        };
2199        std::os::unix::fs::symlink(&source, native_bin.join("mbx")).unwrap();
2200        struct IsolatedHostExecutor {
2201            path: String,
2202            home: String,
2203        }
2204        impl CommandExecutor for IsolatedHostExecutor {
2205            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2206                let mut command = command.clone();
2207                command.env.insert("PATH".into(), self.path.clone());
2208                command.env.insert("HOME".into(), self.home.clone());
2209                targets::ProcessExecutor.execute(&command)
2210            }
2211        }
2212        let executor = IsolatedHostExecutor {
2213            path: format!("{}:/usr/bin:/bin", native_bin.display()),
2214            home: home.display().to_string(),
2215        };
2216
2217        install("1.22.0");
2218        let first =
2219            sync_mbx_binary_from_native(&CacheHost::Local, &native("1.22.0"), &cache, &executor)
2220                .unwrap();
2221        let copy = first.path;
2222        assert_eq!(copy, cache.join(".mjolnir/bin/mbx"));
2223        assert_eq!(
2224            std::fs::read(&copy).unwrap(),
2225            std::fs::read(&source).unwrap()
2226        );
2227        assert_eq!(
2228            std::fs::metadata(&copy).unwrap().permissions().mode() & 0o777,
2229            0o755
2230        );
2231        let original_inode = std::fs::metadata(&copy).unwrap().ino();
2232
2233        let unchanged =
2234            sync_mbx_binary_from_native(&CacheHost::Local, &native("1.22.0"), &cache, &executor)
2235                .unwrap();
2236        assert_eq!(unchanged.path, copy);
2237        assert_eq!(std::fs::metadata(&copy).unwrap().ino(), original_inode);
2238
2239        install("1.23.0");
2240        sync_mbx_binary_from_native(&CacheHost::Local, &native("1.23.0"), &cache, &executor)
2241            .unwrap();
2242        assert_ne!(std::fs::metadata(&copy).unwrap().ino(), original_inode);
2243        let version = executor
2244            .execute(&CommandSpec::new(
2245                copy.to_string_lossy().into_owned(),
2246                ["--version"],
2247            ))
2248            .unwrap();
2249        assert_eq!(
2250            String::from_utf8_lossy(&version.stdout).trim(),
2251            "mbx 1.23.0"
2252        );
2253        assert_eq!(
2254            std::fs::read_dir(cache.join(".mjolnir/bin"))
2255                .unwrap()
2256                .count(),
2257            2,
2258            "publication leaves only the copy and its cross-process lock file"
2259        );
2260    }
2261
2262    #[test]
2263    fn cache_sync_reprobes_and_retries_after_another_process_changes_the_host_binary() {
2264        struct ReprobeExecutor {
2265            syncs: std::sync::atomic::AtomicUsize,
2266            commands: Mutex<Vec<CommandSpec>>,
2267        }
2268
2269        impl CommandExecutor for ReprobeExecutor {
2270            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2271                self.commands.lock().unwrap().push(command.clone());
2272                let (status, stdout, stderr) = match command.purpose.as_str() {
2273                    "synchronize native mbx into the shared cache"
2274                        if self.syncs.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 =>
2275                    {
2276                        (75, Vec::new(), b"native mbx changed".to_vec())
2277                    }
2278                    "synchronize native mbx into the shared cache" => {
2279                        (0, b"synced\n".to_vec(), Vec::new())
2280                    }
2281                    "read the container host mbx version" => {
2282                        (0, b"/opt/mbx/current\nmbx 1.23.0".to_vec(), Vec::new())
2283                    }
2284                    purpose => bail!("unexpected command purpose {purpose}"),
2285                };
2286                Ok(CommandOutput {
2287                    status,
2288                    stdout,
2289                    stderr,
2290                })
2291            }
2292        }
2293
2294        let directory = PathBuf::from("/tmp/mjolnir-mbx-retry");
2295        let executor = ReprobeExecutor {
2296            syncs: std::sync::atomic::AtomicUsize::new(0),
2297            commands: Mutex::new(Vec::new()),
2298        };
2299        let binary = sync_mbx_binary_from_native(
2300            &CacheHost::Local,
2301            &NativeMbx {
2302                program: PathBuf::from("/opt/mbx/old"),
2303                version: "1.22.0".into(),
2304            },
2305            &directory,
2306            &executor,
2307        )
2308        .unwrap();
2309
2310        assert_eq!(binary.version, "1.23.0");
2311        assert_eq!(
2312            executor
2313                .commands
2314                .lock()
2315                .unwrap()
2316                .iter()
2317                .map(|command| command.purpose.as_str())
2318                .collect::<Vec<_>>(),
2319            [
2320                "synchronize native mbx into the shared cache",
2321                "read the container host mbx version",
2322                "synchronize native mbx into the shared cache",
2323            ]
2324        );
2325    }
2326
2327    #[cfg(target_os = "linux")]
2328    #[test]
2329    fn cache_sync_without_flock_rechecks_after_rename_and_resynchronizes() {
2330        use std::os::unix::fs::{PermissionsExt, symlink};
2331
2332        let root = tempfile::tempdir().unwrap();
2333        let tools = root.path().join("tools");
2334        let home = root.path().join("home");
2335        let cache = root.path().join("cache");
2336        let old_source = root.path().join("mbx-1.22.0");
2337        let new_source = root.path().join("mbx-1.23.0");
2338        let native_path = tools.join("mbx");
2339        let flipped = root.path().join("flipped");
2340        std::fs::create_dir_all(&tools).unwrap();
2341        std::fs::create_dir_all(&home).unwrap();
2342
2343        for name in [
2344            "sh", "dirname", "mkdir", "readlink", "mktemp", "chmod", "wc", "mv", "rm", "ln",
2345        ] {
2346            let executable = std::env::split_paths(&std::env::var_os("PATH").unwrap())
2347                .map(|directory| directory.join(name))
2348                .find(|candidate| candidate.is_file())
2349                .unwrap_or_else(|| panic!("could not find test utility {name}"));
2350            symlink(executable, tools.join(name)).unwrap();
2351        }
2352
2353        let real_cp = std::env::split_paths(&std::env::var_os("PATH").unwrap())
2354            .map(|directory| directory.join("cp"))
2355            .find(|candidate| candidate.is_file())
2356            .unwrap();
2357        let cp_wrapper = tools.join("cp");
2358        std::fs::write(
2359            &cp_wrapper,
2360            r#"#!/bin/sh
2361"$MBX_TEST_REAL_CP" "$@" || exit $?
2362if [ ! -e "$MBX_TEST_FLIPPED" ]; then
2363    : > "$MBX_TEST_FLIPPED"
2364    rm -f -- "$MBX_TEST_NATIVE"
2365    ln -s -- "$MBX_TEST_NEW_SOURCE" "$MBX_TEST_NATIVE"
2366fi"#,
2367        )
2368        .unwrap();
2369        std::fs::set_permissions(&cp_wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
2370        for (path, version) in [(&old_source, "1.22.0"), (&new_source, "1.23.0")] {
2371            std::fs::write(path, format!("#!/bin/sh\nprintf 'mbx {version}\\n'\n")).unwrap();
2372            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2373        }
2374        symlink(&old_source, &native_path).unwrap();
2375
2376        struct FallbackExecutor {
2377            tools: PathBuf,
2378            home: PathBuf,
2379            real_cp: PathBuf,
2380            flipped: PathBuf,
2381            native: PathBuf,
2382            new_source: PathBuf,
2383        }
2384        impl CommandExecutor for FallbackExecutor {
2385            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2386                let mut command = command.clone();
2387                command
2388                    .env
2389                    .insert("PATH".into(), self.tools.display().to_string());
2390                command
2391                    .env
2392                    .insert("HOME".into(), self.home.display().to_string());
2393                command.env.insert(
2394                    "MBX_TEST_REAL_CP".into(),
2395                    self.real_cp.display().to_string(),
2396                );
2397                command.env.insert(
2398                    "MBX_TEST_FLIPPED".into(),
2399                    self.flipped.display().to_string(),
2400                );
2401                command
2402                    .env
2403                    .insert("MBX_TEST_NATIVE".into(), self.native.display().to_string());
2404                command.env.insert(
2405                    "MBX_TEST_NEW_SOURCE".into(),
2406                    self.new_source.display().to_string(),
2407                );
2408                targets::ProcessExecutor.execute(&command)
2409            }
2410        }
2411        let executor = FallbackExecutor {
2412            tools,
2413            home,
2414            real_cp,
2415            flipped: flipped.clone(),
2416            native: native_path,
2417            new_source,
2418        };
2419
2420        let binary = sync_mbx_binary_from_native(
2421            &CacheHost::Local,
2422            &NativeMbx {
2423                program: old_source,
2424                version: "1.22.0".into(),
2425            },
2426            &cache,
2427            &executor,
2428        )
2429        .unwrap();
2430
2431        assert!(
2432            flipped.exists(),
2433            "the test copy did not switch host versions"
2434        );
2435        assert_eq!(binary.version, "1.23.0");
2436        let copied = targets::ProcessExecutor
2437            .execute(&CommandSpec::new(
2438                binary.path.to_string_lossy().into_owned(),
2439                ["--version"],
2440            ))
2441            .unwrap();
2442        assert_eq!(String::from_utf8_lossy(&copied.stdout).trim(), "mbx 1.23.0");
2443    }
2444
2445    #[cfg(target_os = "linux")]
2446    #[test]
2447    fn cross_process_cache_sync_cannot_publish_a_stale_host_binary_last() {
2448        use std::os::unix::fs::{PermissionsExt, symlink};
2449        use std::sync::atomic::{AtomicBool, Ordering};
2450
2451        let root = tempfile::tempdir().unwrap();
2452        let source_dir = root.path().join("sources");
2453        let native_bin = root.path().join("native-bin");
2454        let wrapper_bin = root.path().join("wrapper-bin");
2455        let home = root.path().join("home");
2456        let cache = root.path().join("cache");
2457        let lock = cache.join(".mjolnir/bin/.mbx.lock");
2458        let ready = root.path().join("lock-ready");
2459        let release = root.path().join("release-lock");
2460        std::fs::create_dir_all(&source_dir).unwrap();
2461        std::fs::create_dir_all(&native_bin).unwrap();
2462        std::fs::create_dir_all(&wrapper_bin).unwrap();
2463        std::fs::create_dir_all(&home).unwrap();
2464        std::fs::create_dir_all(lock.parent().unwrap()).unwrap();
2465
2466        let old_source = source_dir.join("mbx-1.22.0");
2467        let new_source = source_dir.join("mbx-1.23.0");
2468        for (path, version) in [(&old_source, "1.22.0"), (&new_source, "1.23.0")] {
2469            std::fs::write(path, format!("#!/bin/sh\nprintf 'mbx {version}\\n'\n")).unwrap();
2470            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2471        }
2472        let native_path = native_bin.join("mbx");
2473        symlink(&old_source, &native_path).unwrap();
2474
2475        // Mark the old process as soon as it reaches flock, then delegate to
2476        // the real utility. This proves it is waiting on the held lock before
2477        // the native install path changes.
2478        let flock_wrapper = wrapper_bin.join("flock");
2479        std::fs::write(
2480            &flock_wrapper,
2481            "#!/bin/sh\n[ -z \"${MBX_TEST_FLOCK_MARKER:-}\" ] || : > \"$MBX_TEST_FLOCK_MARKER\"\nexec /usr/bin/flock \"$@\"\n",
2482        )
2483        .unwrap();
2484        std::fs::set_permissions(&flock_wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
2485
2486        let path_value = format!(
2487            "{}:{}:/usr/bin:/bin",
2488            wrapper_bin.display(),
2489            native_bin.display()
2490        );
2491        let executor = crate::targets::ProcessExecutor;
2492        let holder_script = r#"set -eu
2493exec 9>"$1"
2494flock -x 9
2495: > "$2"
2496while [ ! -e "$3" ]; do sleep 0.01; done"#;
2497        let mut holder = CommandSpec::new(
2498            "sh",
2499            vec![
2500                "-c".into(),
2501                holder_script.into(),
2502                "mbx-lock-holder".into(),
2503                lock.to_string_lossy().into_owned(),
2504                ready.to_string_lossy().into_owned(),
2505                release.to_string_lossy().into_owned(),
2506            ],
2507        )
2508        .purpose("hold test mbx lock");
2509        holder.env.insert("PATH".into(), path_value.clone());
2510        holder.env.insert("HOME".into(), home.display().to_string());
2511        let holder_thread = std::thread::spawn(move || executor.execute(&holder));
2512
2513        let deadline = std::time::Instant::now() + Duration::from_secs(3);
2514        while !ready.exists() && std::time::Instant::now() < deadline {
2515            std::thread::sleep(Duration::from_millis(10));
2516        }
2517        if !ready.exists() {
2518            std::fs::write(&release, "release").unwrap();
2519            let _ = holder_thread.join();
2520            panic!("the test lock holder did not acquire flock");
2521        }
2522
2523        let make_sync = |source: &Path, version: &str| {
2524            let mut command = CommandSpec::new(
2525                "sh",
2526                [
2527                    "-c".to_owned(),
2528                    SYNC_MBX_BINARY_SCRIPT.to_owned(),
2529                    "test-mbx-sync".to_owned(),
2530                    source.to_string_lossy().into_owned(),
2531                    cache_binary_path(&cache).to_string_lossy().into_owned(),
2532                    version.to_owned(),
2533                    NATIVE_VERSION_SCRIPT.to_owned(),
2534                ],
2535            )
2536            .purpose("run cross-process mbx sync test");
2537            command.clear_env = true;
2538            command.env.insert("PATH".into(), path_value.clone());
2539            command
2540                .env
2541                .insert("HOME".into(), home.display().to_string());
2542            command
2543        };
2544
2545        let old_marker = root.path().join("old-reached-flock");
2546        let old_command = make_sync(&old_source, "1.22.0");
2547        let old_executor = crate::targets::ProcessExecutor;
2548        let old_done = std::sync::Arc::new(AtomicBool::new(false));
2549        let old_done_thread = old_done.clone();
2550        let mut old_command = old_command;
2551        old_command.env.insert(
2552            "MBX_TEST_FLOCK_MARKER".into(),
2553            old_marker.display().to_string(),
2554        );
2555        let old_thread = std::thread::spawn(move || {
2556            let result = old_executor.execute(&old_command);
2557            old_done_thread.store(true, Ordering::SeqCst);
2558            result
2559        });
2560        let deadline = std::time::Instant::now() + Duration::from_secs(3);
2561        while !old_marker.exists() && std::time::Instant::now() < deadline {
2562            std::thread::sleep(Duration::from_millis(10));
2563        }
2564        let old_waited = old_marker.exists() && !old_done.load(Ordering::SeqCst);
2565
2566        std::fs::remove_file(&native_path).unwrap();
2567        symlink(&new_source, &native_path).unwrap();
2568        let new_command = make_sync(&new_source, "1.23.0");
2569        let new_executor = crate::targets::ProcessExecutor;
2570        let new_thread = std::thread::spawn(move || new_executor.execute(&new_command));
2571        std::thread::sleep(Duration::from_millis(40));
2572        std::fs::write(&release, "release").unwrap();
2573
2574        let holder_output = holder_thread.join().unwrap().unwrap();
2575        let old_output = old_thread.join().unwrap().unwrap();
2576        let new_output = new_thread.join().unwrap().unwrap();
2577        assert_eq!(holder_output.status, 0, "{holder_output:?}");
2578        assert!(old_waited, "the stale synchronizer did not wait on flock");
2579        assert_eq!(old_output.status, 75, "{old_output:?}");
2580        assert_eq!(new_output.status, 0, "{new_output:?}");
2581
2582        let version = crate::targets::ProcessExecutor
2583            .execute(&CommandSpec::new(
2584                cache_binary_path(&cache).to_string_lossy().into_owned(),
2585                ["--version"],
2586            ))
2587            .unwrap();
2588        assert_eq!(
2589            String::from_utf8_lossy(&version.stdout).trim(),
2590            "mbx 1.23.0"
2591        );
2592    }
2593
2594    #[test]
2595    fn absent_or_too_old_native_mbx_leaves_the_existing_cache_copy() {
2596        struct ProbeAnswer {
2597            status: i32,
2598            stdout: Vec<u8>,
2599        }
2600
2601        impl CommandExecutor for ProbeAnswer {
2602            fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
2603                Ok(CommandOutput {
2604                    status: self.status,
2605                    stdout: self.stdout.clone(),
2606                    stderr: Vec::new(),
2607                })
2608            }
2609        }
2610
2611        let root = tempfile::tempdir().unwrap();
2612        let cache = root.path().join("cache");
2613        let copy = cache_binary_path(&cache);
2614        std::fs::create_dir_all(copy.parent().unwrap()).unwrap();
2615        std::fs::write(&copy, b"previous compatible copy").unwrap();
2616
2617        for answer in [
2618            ProbeAnswer {
2619                status: 1,
2620                stdout: Vec::new(),
2621            },
2622            ProbeAnswer {
2623                status: 0,
2624                stdout: "/opt/old/mbx\nmbx 1.21.0".into(),
2625            },
2626        ] {
2627            let result = sync_current_mbx_binary(&CacheHost::Local, &cache, &answer).unwrap();
2628            assert!(matches!(result, CachedMbxSync::Unavailable(_)));
2629            assert_eq!(std::fs::read(&copy).unwrap(), b"previous compatible copy");
2630        }
2631    }
2632
2633    #[test]
2634    fn the_preview_reports_native_cache_values_without_creating_directories() {
2635        let _isolated = isolated();
2636        let executor = ProbeExecutor::new(&native_host());
2637        let preview = preview_build_cache(&configured_local_machine(), &executor)
2638            .unwrap()
2639            .unwrap();
2640        assert_eq!(preview.native_mbx.as_deref(), Some(MBX_VERSION));
2641        assert_eq!(preview.mbx_profile_file, None);
2642        assert_eq!(
2643            preview.directory,
2644            Some(PathBuf::from("/mnt/fast/mbx-cache"))
2645        );
2646        assert_eq!(
2647            preview.max_total_size,
2648            Some(BuildCacheLimit::MbxDefault(None))
2649        );
2650        assert_eq!(preview.off_reason, None);
2651        // Profile previews carry the shared script, including its update-only
2652        // mkdir branch, but do not execute a host mkdir command.
2653        assert!(!executor.ran().iter().any(|line| line.starts_with("mkdir ")));
2654    }
2655}