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 local directories mbx uses for its cache and managed targets.
1013pub(crate) fn local_storage_paths() -> Vec<PathBuf> {
1014    let config_file = dirs::config_dir()
1015        .map(|directory| directory.join("mbx/config.toml"))
1016        .and_then(|path| std::fs::read_to_string(path).ok());
1017    let cache_override = std::env::var_os("MBX_CACHE_DIR")
1018        .filter(|value| !value.is_empty())
1019        .map(PathBuf::from);
1020    let target_root_override = std::env::var_os("MBX_TARGET_ROOT")
1021        .filter(|value| !value.is_empty())
1022        .map(PathBuf::from);
1023    let default_cache = dirs::cache_dir().map(|directory| directory.join("mbx"));
1024
1025    resolve_local_storage_paths(
1026        cache_override,
1027        target_root_override,
1028        config_file.as_deref(),
1029        default_cache,
1030    )
1031}
1032
1033fn resolve_local_storage_paths(
1034    cache_override: Option<PathBuf>,
1035    target_root_override: Option<PathBuf>,
1036    config_file: Option<&str>,
1037    default_cache: Option<PathBuf>,
1038) -> Vec<PathBuf> {
1039    let document = config_file.and_then(|text| toml::from_str::<toml::Value>(text).ok());
1040    let configured_cache = document
1041        .as_ref()
1042        .and_then(|document| document.get("cache_dir"))
1043        .and_then(toml::Value::as_str)
1044        .map(PathBuf::from);
1045    let Some(cache) = cache_override.or(configured_cache).or(default_cache) else {
1046        return Vec::new();
1047    };
1048    let configured_target_root = document
1049        .as_ref()
1050        .and_then(|document| document.get("target"))
1051        .and_then(|target| target.get("root"))
1052        .and_then(toml::Value::as_str)
1053        .map(PathBuf::from);
1054    let target_root = target_root_override
1055        .or(configured_target_root)
1056        .map(|root| {
1057            if root.is_absolute() {
1058                root
1059            } else {
1060                cache.join(root)
1061            }
1062        })
1063        .unwrap_or_else(|| cache.join("targets"));
1064    vec![cache, target_root]
1065}
1066
1067/// The `[target] root` a host configuration sets, when it lies outside the
1068/// cache directory and therefore needs its own mount.
1069fn relocated_target_root(config_file: &str, directory: &Path) -> Option<PathBuf> {
1070    let document: toml::Value = toml::from_str(config_file)
1071        .map_err(|error| tracing::warn!("the host mbx configuration is unreadable: {error}"))
1072        .ok()?;
1073    let root = document.get("target")?.get("root")?.as_str()?;
1074    let root = directory.join(root);
1075    (!root.starts_with(directory)).then_some(root)
1076}
1077
1078const NEAREST_ANCESTOR_SCRIPT: &str = r#"d=$1
1079while [ ! -d "$d" ]; do
1080    parent=$(dirname -- "$d")
1081    if [ "$parent" = "$d" ]; then
1082        break
1083    fi
1084    d=$parent
1085done
1086printf '%s' "$d""#;
1087
1088/// The deepest existing directory at or above `directory`. The cache directory
1089/// may not exist yet, and both `df` and the reflink probe need a real one.
1090fn nearest_existing_ancestor(
1091    host: &CacheHost,
1092    directory: &Path,
1093    executor: &impl CommandExecutor,
1094) -> Result<PathBuf> {
1095    let command = host.shell_command(
1096        NEAREST_ANCESTOR_SCRIPT,
1097        LABEL,
1098        [directory.to_string_lossy().into_owned()],
1099        "locate the build cache volume",
1100    );
1101    let output = checked(executor.execute(&command)?, &command)?;
1102    let path = PathBuf::from(String::from_utf8(output.stdout).context("decode cache ancestor")?);
1103    ensure!(
1104        path.is_absolute(),
1105        "build cache volume {} is not absolute",
1106        path.display()
1107    );
1108    Ok(path)
1109}
1110
1111const REFLINK_SCRIPT: &str = r#"dir=$1
1112d=$(mktemp -d "$dir/.mj-reflink.XXXXXX") || exit 1
1113printf x > "$d/a" && cp --reflink=always "$d/a" "$d/b"
1114status=$?
1115rm -rf -- "$d"
1116exit $status"#;
1117
1118/// Whether the cache volume can clone files instead of copying their bytes.
1119/// Reflinks are what make restoring a cached output nearly free, so a host
1120/// without them defaults to running without the cache.
1121fn reflinks_supported(
1122    host: &CacheHost,
1123    volume: &Path,
1124    executor: &impl CommandExecutor,
1125) -> Result<bool> {
1126    let command = host.shell_command(
1127        REFLINK_SCRIPT,
1128        LABEL,
1129        [volume.to_string_lossy().into_owned()],
1130        "probe the build cache volume for reflinks",
1131    );
1132    Ok(executor.execute(&command)?.status == 0)
1133}
1134
1135const CROSS_REFLINK_SCRIPT: &str = r#"src=$1
1136dst=$2
1137s=$(mktemp -d "$src/.mj-reflink.XXXXXX") || exit 1
1138d=$(mktemp -d "$dst/.mj-reflink.XXXXXX") || { rm -rf -- "$s"; exit 1; }
1139printf x > "$s/a" && cp --reflink=always "$s/a" "$d/b"
1140status=$?
1141rm -rf -- "$s" "$d"
1142exit $status"#;
1143
1144/// Whether a cached output can be cloned from the store into the managed
1145/// target root instead of copied. `FICLONE` fails across two mounts even when
1146/// both are the same filesystem, so this asks the pair rather than each side.
1147fn cross_reflinks_supported(
1148    host: &CacheHost,
1149    store: &Path,
1150    target_root: &Path,
1151    executor: &impl CommandExecutor,
1152) -> Result<bool> {
1153    let command = host.shell_command(
1154        CROSS_REFLINK_SCRIPT,
1155        LABEL,
1156        [
1157            store.to_string_lossy().into_owned(),
1158            target_root.to_string_lossy().into_owned(),
1159        ],
1160        "probe the managed target root for reflinks from the build cache",
1161    );
1162    Ok(executor.execute(&command)?.status == 0)
1163}
1164
1165fn create_directory(
1166    host: &CacheHost,
1167    directory: &Path,
1168    executor: &impl CommandExecutor,
1169) -> Result<()> {
1170    let command = host.command(
1171        vec![
1172            "mkdir".to_owned(),
1173            "-p".to_owned(),
1174            "--".to_owned(),
1175            directory.to_string_lossy().into_owned(),
1176        ],
1177        "create the build cache directory",
1178    );
1179    checked(executor.execute(&command)?, &command).map(|_| ())
1180}
1181
1182/// A filesystem mbx cannot use. It refuses NFS outright, and file locks over
1183/// FUSE, virtiofs, and 9p are unreliable, which a shared store depends on.
1184fn unusable_filesystem(
1185    host: &CacheHost,
1186    directory: &Path,
1187    executor: &impl CommandExecutor,
1188) -> Result<Option<&'static str>> {
1189    let filesystems =
1190        targets::probe_filesystem_types(host.ssh(), &[directory.to_path_buf()], executor)?;
1191    let filesystem = filesystems
1192        .first()
1193        .context("the filesystem probe named no filesystem")?;
1194    // `overlay_unsupported_filesystem` already groups virtiofs and 9p with the
1195    // network filesystems. The other reasons it gives are about stacking an
1196    // overlay, which a plain read-write bind mount does not do.
1197    Ok(targets::overlay_unsupported_filesystem(filesystem)
1198        .filter(|reason| matches!(*reason, "network filesystem" | "FUSE filesystem")))
1199}
1200
1201fn checked(output: CommandOutput, command: &CommandSpec) -> Result<CommandOutput> {
1202    if output.status == 0 {
1203        return Ok(output);
1204    }
1205    bail!(
1206        "{} failed with status {}: {}",
1207        command.purpose,
1208        output.status,
1209        String::from_utf8_lossy(&output.stderr).trim()
1210    )
1211}
1212
1213// -- per-session decision -------------------------------------------------
1214
1215/// Whether the primary repository is a Cargo workspace, read from the host
1216/// mirror the clone cache prepared. A repository whose manifest is not at its
1217/// root, and a session whose clone cache was not prepared, run without mbx.
1218pub(super) fn primary_repository_is_rust(
1219    host: &CacheHost,
1220    mirror: &Path,
1221    executor: &impl CommandExecutor,
1222) -> bool {
1223    let command = host.command(
1224        vec![
1225            "git".to_owned(),
1226            "--git-dir".to_owned(),
1227            mirror.to_string_lossy().into_owned(),
1228            "cat-file".to_owned(),
1229            "-e".to_owned(),
1230            "HEAD:Cargo.toml".to_owned(),
1231        ],
1232        "detect a Cargo workspace in the session repository",
1233    );
1234    matches!(executor.execute(&command), Ok(output) if output.status == 0)
1235}
1236
1237/// Decide the build cache for one session and attach its mounts, returning the
1238/// placement to record on the session. Resumes and moves resolve current
1239/// machine policy instead of reviving a saved session budget.
1240pub(super) fn prepare(
1241    target: &targets::TargetTemplate,
1242    session: &mj_core::state::SessionRecord,
1243    bundle: Option<&targets::ProjectBundleSpec>,
1244    clone_cache: Option<&super::git_cache::PreparedCloneCache>,
1245    mounts: &mut Vec<targets::AdditionalMount>,
1246    executor: &impl CommandExecutor,
1247) -> Option<SessionBuildCache> {
1248    // This function prepares container mounts. Budgets always come from the
1249    // machine; a previously recorded budget is never revived on recreation.
1250    // A session at the legacy shared `/workspace` would collide with every
1251    // other legacy session in mbx's path-keyed records.
1252    session.container_workspace.as_ref()?;
1253    let resolved = resolve(target, executor)?;
1254    let host = supported_host(target)?.0;
1255    if session.build_cache.is_none() {
1256        let mirror = clone_cache?.mirror_for(&bundle?.primary)?;
1257        if !primary_repository_is_rust(&host, mirror, executor) {
1258            return None;
1259        }
1260    }
1261    match sync_current_mbx_binary(&host, &resolved.directory, executor) {
1262        Ok(CachedMbxSync::Ready(_)) => {}
1263        Ok(CachedMbxSync::Unavailable(reason)) => {
1264            tracing::warn!(
1265                host = host.key(),
1266                "sessions run without the shared build cache: {reason}"
1267            );
1268            executor.notify_notice(&format!(
1269                "The shared Rust build cache is unavailable: {reason}. The session will start without it."
1270            ));
1271            return None;
1272        }
1273        Err(error) => {
1274            tracing::warn!(
1275                host = host.key(),
1276                "the shared mbx binary could not be synchronized; the session runs without the build cache: {error:#}"
1277            );
1278            executor.notify_notice(&format!(
1279                "The shared Rust build cache is unavailable: {error:#}. The session will start without it."
1280            ));
1281            return None;
1282        }
1283    }
1284    let build_cache = SessionBuildCache {
1285        host: host.key(),
1286        directory: resolved.directory,
1287        max_size: None,
1288        target_root: resolved.target_root,
1289    };
1290    attach_mounts(&build_cache, mounts).then_some(build_cache)
1291}
1292
1293/// Mount the cache, and a relocated target root, read-write at the same
1294/// absolute paths the host uses. An attached directory that already covers one
1295/// of those paths wins, and the session runs without the cache.
1296fn attach_mounts(
1297    build_cache: &SessionBuildCache,
1298    mounts: &mut Vec<targets::AdditionalMount>,
1299) -> bool {
1300    let mut wanted = vec![build_cache.directory.clone()];
1301    wanted.extend(build_cache.target_root.iter().cloned());
1302    for destination in &wanted {
1303        if mounts.iter().any(|mount| {
1304            mount.destination.starts_with(destination)
1305                || destination.starts_with(&mount.destination)
1306        }) {
1307            tracing::warn!(
1308                destination = %destination.display(),
1309                "an attached directory overlaps the build cache, so this session runs without it"
1310            );
1311            return false;
1312        }
1313    }
1314    for directory in std::iter::once(&build_cache.directory).chain(build_cache.target_root.iter()) {
1315        mounts.push(targets::AdditionalMount {
1316            source: directory.clone(),
1317            destination: directory.clone(),
1318            access: targets::MountAccess::Rw,
1319        });
1320    }
1321    true
1322}
1323
1324/// Read the configuration on the host that actually owns this container.
1325/// The named template may have been removed or reassigned since creation.
1326pub(super) fn host_for_locator(target: &targets::TargetLocator) -> Option<CacheHost> {
1327    match target {
1328        targets::TargetLocator::LocalPodman { .. } | targets::TargetLocator::LocalDocker { .. } => {
1329            Some(CacheHost::Local)
1330        }
1331        targets::TargetLocator::SshPodman { ssh, .. }
1332        | targets::TargetLocator::SshDocker { ssh, .. } => Some(CacheHost::Ssh(ssh.clone())),
1333        _ => None,
1334    }
1335}
1336
1337/// Refresh a new-scheme session's copy from the host that owns its container.
1338pub(super) fn sync_mbx_binary_for_container(
1339    target: &targets::TargetLocator,
1340    directory: &Path,
1341    executor: &impl CommandExecutor,
1342) -> Result<CachedMbxSync> {
1343    let host = host_for_locator(target).context("build cache has no container host")?;
1344    sync_current_mbx_binary(&host, directory, executor)
1345}
1346
1347#[cfg(test)]
1348mod tests {
1349    use super::*;
1350    use crate::targets::{ContainerTemplate, SshTarget, TargetTemplate};
1351    use mj_core::config::ImagePullPolicy;
1352    use std::sync::Mutex;
1353
1354    /// The resolution cache is process-wide, so tests that exercise it run one
1355    /// at a time and start from an empty cache.
1356    static ISOLATED: Mutex<()> = Mutex::new(());
1357
1358    fn isolated() -> std::sync::MutexGuard<'static, ()> {
1359        let guard = ISOLATED.lock().unwrap_or_else(|error| error.into_inner());
1360        RESOLUTIONS.lock().expect("mbx resolutions").clear();
1361        APPLICATIONS.lock().expect("mbx applications").clear();
1362        guard
1363    }
1364
1365    /// Answers canned commands by a substring of their joined argument list.
1366    #[derive(Default)]
1367    struct ProbeExecutor {
1368        answers: Vec<(&'static str, i32, String)>,
1369        seen: Mutex<Vec<String>>,
1370    }
1371
1372    impl ProbeExecutor {
1373        fn new(answers: &[(&'static str, i32, &str)]) -> Self {
1374            Self {
1375                answers: answers
1376                    .iter()
1377                    .map(|(needle, status, stdout)| (*needle, *status, (*stdout).to_owned()))
1378                    .chain(std::iter::once(("uname -sm", 0, "Linux x86_64\n".into())))
1379                    .collect(),
1380                seen: Mutex::new(Vec::new()),
1381            }
1382        }
1383
1384        fn ran(&self) -> Vec<String> {
1385            self.seen.lock().unwrap().clone()
1386        }
1387    }
1388
1389    impl CommandExecutor for ProbeExecutor {
1390        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1391            let line = format!("{} {}", command.program, command.args.join(" "));
1392            self.seen.lock().unwrap().push(line.clone());
1393            // Undo the quoting added by join_remote_command for fixture matching.
1394            let searchable = if command.program == "ssh" {
1395                line.replace("'\\''", "'").replace("' '", " ")
1396            } else {
1397                line.clone()
1398            };
1399            if searchable.contains("hel-mbx-profile") {
1400                return Ok(CommandOutput {
1401                    status: 0,
1402                    stdout: b"preview\n~/.profile\nposix\n/home/jonathan/.local/share/mbx/bin"
1403                        .to_vec(),
1404                    stderr: Vec::new(),
1405                });
1406            }
1407            for (needle, status, stdout) in &self.answers {
1408                if searchable.contains(needle) {
1409                    return Ok(CommandOutput {
1410                        status: *status,
1411                        stdout: stdout.clone().into_bytes(),
1412                        stderr: Vec::new(),
1413                    });
1414                }
1415            }
1416            Ok(CommandOutput {
1417                status: 127,
1418                stdout: Vec::new(),
1419                stderr: format!("no canned answer for {line}").into_bytes(),
1420            })
1421        }
1422    }
1423
1424    fn container(build_cache: Option<TargetBuildCache>) -> ContainerTemplate {
1425        ContainerTemplate {
1426            image: "example/image:latest".into(),
1427            pull_policy: ImagePullPolicy::Missing,
1428            extra_run_args: Vec::new(),
1429            workspace_storage: Default::default(),
1430            build_cache,
1431        }
1432    }
1433
1434    fn podman(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
1435        TargetTemplate::LocalPodman(container(build_cache))
1436    }
1437
1438    fn docker(build_cache: Option<TargetBuildCache>) -> TargetTemplate {
1439        TargetTemplate::LocalDocker(container(build_cache))
1440    }
1441
1442    /// The settings draft's view of this machine with blank build cache
1443    /// fields.
1444    fn configured_local_machine() -> mj_core::config::Machine {
1445        serde_json::from_value(serde_json::json!({"kind": "local"})).unwrap()
1446    }
1447
1448    /// A native mbx's `--version` answer at the release containers run.
1449    fn current_native_mbx() -> String {
1450        format!("/usr/local/bin/mbx\nmbx {MBX_VERSION}")
1451    }
1452
1453    fn native_host() -> Vec<(&'static str, i32, &'static str)> {
1454        vec![
1455            ("$resolved\" --version", 0, "/usr/local/bin/mbx\nmbx 1.22.0"),
1456            (
1457                "mbx cache dir --json",
1458                0,
1459                r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1460            ),
1461            ("XDG_CONFIG_HOME", 0, "/home/dev/.config/mbx"),
1462            ("[ -f \"$1\" ]", 3, ""),
1463            ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1464            ("mj-reflink", 0, ""),
1465            ("mkdir -p", 0, ""),
1466            ("stat -f -c %T", 0, "xfs"),
1467            ("source=$1 destination=$2 expected=$3", 0, "synced\n"),
1468        ]
1469    }
1470
1471    fn absent_host() -> Vec<(&'static str, i32, &'static str)> {
1472        vec![("$resolved\" --version", 1, "")]
1473    }
1474
1475    #[test]
1476    fn darwin_hosts_skip_cache_inspection_provisioning_and_reconciliation() {
1477        let _isolated = isolated();
1478        for remote in [false, true] {
1479            for enabled in [None, Some(true)] {
1480                let settings = TargetBuildCache {
1481                    enabled,
1482                    ..Default::default()
1483                };
1484                let machine: mj_core::config::Machine = if remote {
1485                    serde_json::from_value(serde_json::json!({
1486                        "kind": "ssh", "host": "mac.test", "user": "builder", "build_cache": settings,
1487                    }))
1488                    .unwrap()
1489                } else {
1490                    mj_core::config::Machine::Local {
1491                        build_cache: Some(settings.clone()),
1492                    }
1493                };
1494                let target = if remote {
1495                    TargetTemplate::SshPodman {
1496                        ssh: SshTarget {
1497                            destination: "builder@mac.test".into(),
1498                            ssh_args: vec![],
1499                        },
1500                        container: container(Some(settings)),
1501                    }
1502                } else {
1503                    podman(Some(settings))
1504                };
1505                // An installed native mbx must not enable Mjolnir's integration.
1506                let executor = ProbeExecutor::new(&[
1507                    ("uname -sm", 0, "Darwin arm64\n"),
1508                    ("$resolved\" --version", 0, "mbx\nmbx 1.16.0"),
1509                ]);
1510                let preview = preview_build_cache(&machine, &executor).unwrap().unwrap();
1511                assert_eq!(
1512                    preview.off_reason,
1513                    Some(BuildCacheOff::Unavailable(UNSUPPORTED_HOST.into()))
1514                );
1515                assert!(preview.directory.is_none());
1516                assert!(resolve(&target, &executor).is_none());
1517                apply_machine_build_cache(&machine, &[PathBuf::from("/existing/cache")], &executor)
1518                    .unwrap();
1519                let commands = executor.ran();
1520                assert!(!commands.is_empty());
1521                assert!(
1522                    commands
1523                        .iter()
1524                        .all(|command| command.contains("uname") && command.contains("-sm")),
1525                    "{commands:?}"
1526                );
1527                assert!(
1528                    commands
1529                        .iter()
1530                        .all(|command| command.starts_with(if remote { "ssh " } else { "uname " }))
1531                );
1532            }
1533        }
1534    }
1535
1536    #[test]
1537    fn linux_ssh_cache_remains_available_on_any_controller_platform() {
1538        let _isolated = isolated();
1539        let executor = ProbeExecutor::new(&native_host());
1540        let target = TargetTemplate::SshDocker {
1541            ssh: SshTarget {
1542                destination: "builder@linux.test".into(),
1543                ssh_args: vec![],
1544            },
1545            container: container(None),
1546        };
1547        let cache = resolve(&target, &executor).unwrap();
1548        assert_eq!(cache.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1549        assert_eq!(cache.previous_config, cache.config_file);
1550        assert!(
1551            executor.ran().iter().all(
1552                |command| command.starts_with("ssh ") && command.contains("builder@linux.test")
1553            )
1554        );
1555    }
1556
1557    #[test]
1558    fn reconciliation_refreshes_active_mounts_when_cache_policy_is_disabled() {
1559        let _isolated = isolated();
1560        let machine = mj_core::config::Machine::Local {
1561            build_cache: Some(TargetBuildCache {
1562                enabled: Some(false),
1563                ..Default::default()
1564            }),
1565        };
1566        let executor = ProbeExecutor::new(&native_host());
1567        let mounted = PathBuf::from("/existing/cache");
1568
1569        apply_machine_build_cache(&machine, std::slice::from_ref(&mounted), &executor).unwrap();
1570
1571        assert!(executor.ran().iter().any(|command| {
1572            command.contains("source=$1 destination=$2 expected=$3")
1573                && command.contains("/existing/cache/.mjolnir/bin/mbx")
1574        }));
1575    }
1576
1577    #[test]
1578    fn recorded_darwin_cache_fails_explicitly_without_writing() {
1579        let executor = ProbeExecutor::new(&[("uname -sm", 0, "Darwin x86_64")]);
1580        let recorded = SessionBuildCache {
1581            host: "local".into(),
1582            directory: PathBuf::from("/existing/cache"),
1583            max_size: None,
1584            target_root: None,
1585        };
1586        let backend = targets::TargetLocator::LocalPodman {
1587            container_id: "saved-container".into(),
1588            workspace_storage: Default::default(),
1589            borrowed_from: None,
1590        };
1591        let error =
1592            prepare_session_configuration(&Config::default(), &backend, &recorded, &executor)
1593                .unwrap_err();
1594        assert!(format!("{error:#}").contains(UNSUPPORTED_HOST));
1595        assert_eq!(executor.ran(), ["uname -sm"]);
1596    }
1597
1598    #[test]
1599    fn failed_platform_probe_does_not_attempt_cache_operations() {
1600        let executor = ProbeExecutor::new(&[("uname -sm", 1, "")]);
1601        assert!(inspect_host(&CacheHost::Local, &TargetBuildCache::default(), &executor).is_err());
1602        assert_eq!(executor.ran(), ["uname -sm"]);
1603    }
1604
1605    #[test]
1606    fn installed_worker_reads_cache_configuration_from_its_recorded_host() {
1607        let executor = ProbeExecutor::new(&[
1608            ("XDG_CONFIG_HOME", 0, "/home/builder/.config/mbx"),
1609            ("$HOME", 0, "/home/builder"),
1610            ("[ -f \"$1\" ]", 0, "[gc]\nmax_total_size = '50GB'\n"),
1611        ]);
1612        let target = targets::TargetLocator::SshPodman {
1613            ssh: SshTarget {
1614                destination: "builder@recorded-cache.test".into(),
1615                ssh_args: vec![],
1616            },
1617            container_id: "saved-container".into(),
1618            workspace_storage: Default::default(),
1619            borrowed_from: None,
1620        };
1621        let config = host_config_file(&host_for_locator(&target).unwrap(), &executor)
1622            .unwrap()
1623            .unwrap();
1624        assert!(config.contains("50GB"));
1625        assert!(
1626            executor
1627                .seen
1628                .lock()
1629                .unwrap()
1630                .iter()
1631                .all(|command| command.contains("builder@recorded-cache.test"))
1632        );
1633    }
1634
1635    #[test]
1636    fn a_native_mbx_supplies_the_cache_directory_and_its_own_limits() {
1637        let _isolated = isolated();
1638        let executor = ProbeExecutor::new(&[
1639            ("$resolved\" --version", 0, current_native_mbx().as_str()),
1640            (
1641                "mbx cache dir --json",
1642                0,
1643                r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1644            ),
1645            (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1646            (
1647                "[ -f \"$1\" ]",
1648                0,
1649                "cache_dir = \"/mnt/fast/mbx-cache\"\n[gc]\nmax_size = \"500GiB\"\n",
1650            ),
1651            ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1652            ("mj-reflink", 0, ""),
1653            ("mkdir -p", 0, ""),
1654            ("stat -f -c %T", 0, "xfs"),
1655        ]);
1656        let resolved = resolve(&podman(None), &executor).unwrap();
1657        assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1658        // The host's own configuration file carries the budget.
1659        assert_eq!(
1660            configuration::configured_limit(
1661                resolved.config_file.as_deref(),
1662                "gc",
1663                "max_total_size"
1664            )
1665            .unwrap(),
1666            None
1667        );
1668        assert_eq!(resolved.target_root, None);
1669        assert!(resolved.config_file.unwrap().contains("500GiB"));
1670        assert!(
1671            !executor
1672                .ran()
1673                .iter()
1674                .any(|line| line.contains("apply machine")),
1675            "a host configuration is never rewritten"
1676        );
1677    }
1678    // Hard-won: 24f3d72c: settings refresh left sessions using a stale failed host inspection
1679    #[test]
1680    fn looking_at_the_settings_page_lets_the_next_session_see_a_repaired_host() {
1681        let _isolated = isolated();
1682        let broken = ProbeExecutor::new(
1683            &native_host()
1684                .into_iter()
1685                .map(|(needle, status, stdout)| match needle {
1686                    "mj-reflink" => (needle, 1, stdout),
1687                    _ => (needle, status, stdout),
1688                })
1689                .collect::<Vec<_>>(),
1690        );
1691        assert!(
1692            resolve(&podman(None), &broken).is_none(),
1693            "a volume that cannot clone runs without the cache"
1694        );
1695
1696        // The host is repaired, and the user opens the machine's build cache
1697        // page to check.
1698        let repaired = ProbeExecutor::new(&native_host());
1699        let preview = preview_build_cache(&configured_local_machine(), &repaired)
1700            .expect("the host answers")
1701            .expect("a local machine can hold a cache");
1702        assert_eq!(preview.off_reason, None);
1703
1704        assert!(
1705            resolve(&podman(None), &repaired).is_some(),
1706            "the next session asks the repaired host again instead of reusing the old verdict"
1707        );
1708    }
1709
1710    #[test]
1711    fn a_target_root_that_cannot_be_cloned_into_still_gets_the_cache() {
1712        let _isolated = isolated();
1713        let executor = ProbeExecutor::new(&[
1714            ("$resolved\" --version", 0, current_native_mbx().as_str()),
1715            (
1716                "mbx cache dir --json",
1717                0,
1718                r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1719            ),
1720            (r#"printf '%s' "$HOME""#, 0, "/home/dev"),
1721            (
1722                "[ -f \"$1\" ]",
1723                0,
1724                "[target]\nroot = \"/mnt/slow/mbx-targets\"\n",
1725            ),
1726            ("while [ ! -d", 0, "/mnt/fast/mbx-cache"),
1727            // Cloning from the store into the relocated root fails; cloning
1728            // within the store still works.
1729            ("src=$1", 1, ""),
1730            ("mj-reflink", 0, ""),
1731            ("mkdir -p", 0, ""),
1732            ("stat -f -c %T", 0, "xfs"),
1733        ]);
1734        let resolved = resolve(&podman(None), &executor)
1735            .expect("a target root that copies instead of cloning is slower, not unusable");
1736        assert_eq!(
1737            resolved.target_root,
1738            Some(PathBuf::from("/mnt/slow/mbx-targets"))
1739        );
1740        assert!(
1741            executor.ran().iter().any(|line| line.contains("src=$1")),
1742            "the store and the target root are probed as a pair: {:?}",
1743            executor.ran()
1744        );
1745    }
1746
1747    #[test]
1748    fn a_target_root_inside_the_cache_directory_needs_no_second_mount() {
1749        assert_eq!(
1750            relocated_target_root("[target]\nroot = \"targets\"\n", Path::new("/cache")),
1751            None
1752        );
1753        assert_eq!(
1754            relocated_target_root("[target]\nroot = \"/cache/targets\"\n", Path::new("/cache")),
1755            None
1756        );
1757    }
1758
1759    #[test]
1760    fn local_storage_paths_follow_mbx_cache_and_target_root_resolution() {
1761        let default_cache = PathBuf::from("/home/dev/.cache/mbx");
1762        assert_eq!(
1763            resolve_local_storage_paths(
1764                None,
1765                None,
1766                Some(
1767                    "cache_dir = \"/mnt/optane/mbx-cache\"\n\
1768                     [target]\nroot = \"/mnt/optane/mbx-targets\"\n",
1769                ),
1770                Some(default_cache.clone()),
1771            ),
1772            vec![
1773                PathBuf::from("/mnt/optane/mbx-cache"),
1774                PathBuf::from("/mnt/optane/mbx-targets"),
1775            ]
1776        );
1777        assert_eq!(
1778            resolve_local_storage_paths(
1779                None,
1780                None,
1781                Some("cache_dir = \"/mnt/optane/mbx-cache\"\n[target]\nroot = \"views\"\n"),
1782                Some(default_cache.clone()),
1783            ),
1784            vec![
1785                PathBuf::from("/mnt/optane/mbx-cache"),
1786                PathBuf::from("/mnt/optane/mbx-cache/views"),
1787            ]
1788        );
1789        assert_eq!(
1790            resolve_local_storage_paths(None, None, None, Some(default_cache.clone())),
1791            vec![default_cache.clone(), default_cache.join("targets")]
1792        );
1793        assert_eq!(
1794            resolve_local_storage_paths(
1795                Some(PathBuf::from("/env/cache")),
1796                Some(PathBuf::from("views")),
1797                Some("cache_dir = \"/file/cache\"\n[target]\nroot = \"file-views\"\n"),
1798                Some(default_cache),
1799            ),
1800            vec![
1801                PathBuf::from("/env/cache"),
1802                PathBuf::from("/env/cache/views")
1803            ]
1804        );
1805    }
1806
1807    // Hard-won: ecab42fb: non-login SSH PATH hid cargo-installed mbx and selected the wrong cache store
1808    #[test]
1809    fn a_cargo_installed_mbx_off_the_path_is_queried_where_it_was_found() {
1810        let _isolated = isolated();
1811        let found = format!("/home/dev/.cargo/bin/mbx\nmbx {MBX_VERSION}");
1812        let mut answers: Vec<(&'static str, i32, &str)> = native_host();
1813        answers.retain(|(needle, _, _)| *needle != "$resolved\" --version");
1814        answers.push(("$resolved\" --version", 0, found.as_str()));
1815        answers.push((
1816            "/home/dev/.cargo/bin/mbx cache dir --json",
1817            0,
1818            r#"{"version":1,"store":"/mnt/fast/mbx-cache/actions"}"#,
1819        ));
1820        let executor = ProbeExecutor::new(&answers);
1821        let resolved = resolve(&podman(None), &executor).unwrap();
1822        assert_eq!(resolved.directory, PathBuf::from("/mnt/fast/mbx-cache"));
1823        assert_eq!(
1824            resolved.native_mbx.program,
1825            PathBuf::from("/home/dev/.cargo/bin/mbx")
1826        );
1827    }
1828
1829    #[test]
1830    fn an_older_native_mbx_must_not_share_the_store() {
1831        let _isolated = isolated();
1832        let executor = ProbeExecutor::new(&[
1833            (
1834                "$resolved\" --version",
1835                0,
1836                "/home/jonathan/.local/bin/mbx\nmbx 1.15.0",
1837            ),
1838            ("hel-mbx-home", 0, "/home/jonathan"),
1839        ]);
1840        assert!(resolve(&podman(None), &executor).is_none());
1841        let preview = preview_build_cache(&configured_local_machine(), &executor)
1842            .unwrap()
1843            .unwrap();
1844        assert!(matches!(
1845            preview.off_reason,
1846            Some(BuildCacheOff::Unavailable(reason))
1847                if reason.contains("1.15.0") && reason.contains("Settings › Setup › Machines")
1848        ));
1849    }
1850
1851    #[test]
1852    fn a_host_without_mbx_has_no_shared_cache_and_preview_shows_setup_guidance() {
1853        let _isolated = isolated();
1854        let mut answers = absent_host();
1855        answers.push(("hel-mbx-home", 0, "/home/jonathan"));
1856        let executor = ProbeExecutor::new(&answers);
1857        assert!(resolve(&podman(None), &executor).is_none());
1858        let preview = preview_build_cache(&configured_local_machine(), &executor)
1859            .unwrap()
1860            .unwrap();
1861        assert_eq!(preview.native_mbx, None);
1862        assert_eq!(preview.directory, None);
1863        assert!(matches!(
1864            preview.off_reason,
1865            Some(BuildCacheOff::Unavailable(reason))
1866                if reason.contains("Settings › Setup › Machines")
1867        ));
1868        // The shared profile script contains a mkdir branch, but preview must
1869        // not invoke a separate host mkdir command for the cache directory.
1870        assert!(!executor.ran().iter().any(|line| line.starts_with("mkdir ")));
1871    }
1872
1873    #[test]
1874    fn a_native_installation_owns_the_budget_despite_saved_mj_overrides() {
1875        let _isolated = isolated();
1876        let native = current_native_mbx();
1877        let mut answers = vec![
1878            ("$resolved\" --version", 0, native.as_str()),
1879            (
1880                "mbx cache dir --json",
1881                0,
1882                r#"{"store":"/native/cache/actions"}"#,
1883            ),
1884            (
1885                "[ -f \"$1\" ]",
1886                0,
1887                "[gc]\nmax_total_size = '400GiB'\n[target]\nmax_size = 'none'\n",
1888            ),
1889        ];
1890        answers.extend(native_host());
1891        let executor = ProbeExecutor::new(&answers);
1892        let settings = TargetBuildCache {
1893            directory: Some("/ignored".into()),
1894            max_total_size: Some("1GB".into()),
1895            ..Default::default()
1896        };
1897        let inspection = inspect_host(&CacheHost::Local, &settings, &executor).unwrap();
1898        assert!(inspection.preview.user_managed);
1899        assert_eq!(inspection.preview.directory, Some("/native/cache".into()));
1900        assert_eq!(
1901            inspection.preview.max_total_size,
1902            Some(BuildCacheLimit::HostConfiguration(Some("400GiB".into())))
1903        );
1904        assert!(
1905            !executor
1906                .ran()
1907                .iter()
1908                .any(|command| command.contains(".mj-apply.lock"))
1909        );
1910    }
1911
1912    /// Turning the cache on cannot override the host: without reflinks a
1913    /// restore would copy every byte, so sessions still run without it.
1914    #[test]
1915    fn an_enabled_setting_does_not_survive_a_volume_without_reflinks() {
1916        let _isolated = isolated();
1917        let mut answers = native_host();
1918        answers.retain(|(needle, _, _)| *needle != "mj-reflink");
1919        answers.push(("mj-reflink", 1, ""));
1920        let executor = ProbeExecutor::new(&answers);
1921        assert_eq!(
1922            resolve(
1923                &podman(Some(TargetBuildCache {
1924                    enabled: Some(true),
1925                    directory: None,
1926                    max_total_size: None,
1927                    scheduler: Default::default(),
1928                })),
1929                &executor,
1930            ),
1931            None
1932        );
1933        // An unset target preference is overridden by the same host capability.
1934        assert_eq!(resolve(&podman(None), &executor), None);
1935    }
1936
1937    #[test]
1938    fn a_network_filesystem_runs_without_the_cache() {
1939        let _isolated = isolated();
1940        let mut answers = native_host();
1941        answers.retain(|(needle, _, _)| *needle != "stat -f -c %T");
1942        answers.push(("stat -f -c %T", 0, "nfs4"));
1943        let executor = ProbeExecutor::new(&answers);
1944        assert_eq!(resolve(&podman(None), &executor), None);
1945    }
1946
1947    #[test]
1948    fn local_podman_and_local_docker_inspect_one_machine_once() {
1949        let _isolated = isolated();
1950        let executor = ProbeExecutor::new(&native_host());
1951        let first = resolve(&podman(None), &executor).unwrap();
1952        let ran = executor.ran().len();
1953        assert!(ran > 0, "the first resolve inspects the host");
1954        let second = resolve(&docker(None), &executor).unwrap();
1955        assert_eq!(
1956            first, second,
1957            "both engines on this machine share one cache"
1958        );
1959        // The inspection is memoized; creating the directory is not, because a
1960        // remembered inspection says what the host looked like, not that the
1961        // directory still exists.
1962        let added = executor.ran()[ran..].to_vec();
1963        assert_eq!(
1964            added.len(),
1965            1,
1966            "the second runtime is answered from the machine's recorded inspection: {added:?}"
1967        );
1968        assert!(
1969            added[0].contains("mkdir -p"),
1970            "the one repeated command creates the directory: {added:?}"
1971        );
1972    }
1973
1974    #[test]
1975    fn the_preview_reports_what_the_cache_has_already_done() {
1976        let _isolated = isolated();
1977        // Ahead of the configuration read, which tests the same `[ -f ]`.
1978        let mut answers = vec![(
1979            "tally.json",
1980            0,
1981            r#"{"version":1,"since_secs":1789824719,"builds":155,"cached_compilations":12050,"avoided_compiler_ns":6004997818721,"reflinked_bytes":47612059386}"#,
1982        )];
1983        answers.extend(native_host());
1984        let executor = ProbeExecutor::new(&answers);
1985        let preview = preview_build_cache(&configured_local_machine(), &executor)
1986            .expect("the host answers")
1987            .expect("a local machine can hold a cache");
1988        assert_eq!(
1989            preview.stats,
1990            Some(mj_core::state::BuildCacheStats {
1991                builds: 155,
1992                cached_compilations: 12050,
1993                avoided_compiler_ns: 6_004_997_818_721,
1994                reflinked_bytes: 47_612_059_386,
1995            })
1996        );
1997    }
1998    #[test]
1999    fn a_cache_nothing_has_used_yet_reports_no_totals() {
2000        let _isolated = isolated();
2001        // `native_host` answers every `[ -f ]` with 3: no configuration file
2002        // and no tally beside the store.
2003        let executor = ProbeExecutor::new(&native_host());
2004        let preview = preview_build_cache(&configured_local_machine(), &executor)
2005            .expect("the host answers")
2006            .expect("a local machine can hold a cache");
2007        assert_eq!(preview.stats, None);
2008    }
2009    fn bundle() -> targets::ProjectBundleSpec {
2010        targets::ProjectBundleSpec {
2011            primary: "main".into(),
2012            repositories: vec![targets::RepositorySpec {
2013                url: Some("https://github.com/example/main.git".into()),
2014                push_urls: Vec::new(),
2015                destination: "main".into(),
2016                git_ref: None,
2017                reference: None,
2018            }],
2019        }
2020    }
2021
2022    fn clone_cache() -> super::super::git_cache::PreparedCloneCache {
2023        super::super::git_cache::PreparedCloneCache::from_mirrors(
2024            [(
2025                "main".to_owned(),
2026                PathBuf::from("/home/dev/mirror/repo.git"),
2027            )]
2028            .into_iter()
2029            .collect(),
2030        )
2031    }
2032
2033    fn session(container_workspace: Option<&str>) -> mj_core::state::SessionRecord {
2034        let mut record = crate::controller::test_support::checkpoint_test_session("session-1");
2035        record.container_workspace = container_workspace.map(PathBuf::from);
2036        record
2037    }
2038
2039    #[test]
2040    fn a_rust_session_mounts_the_native_cache_and_records_its_synchronized_binary() {
2041        let _isolated = isolated();
2042        let mut answers = native_host();
2043        answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2044        let executor = ProbeExecutor::new(&answers);
2045        let mut mounts = Vec::new();
2046        let build_cache = prepare(
2047            &podman(None),
2048            &session(Some("/workspace/session-1")),
2049            Some(&bundle()),
2050            Some(&clone_cache()),
2051            &mut mounts,
2052            &executor,
2053        )
2054        .expect("a Rust session uses the build cache");
2055        assert_eq!(build_cache.directory, PathBuf::from("/mnt/fast/mbx-cache"));
2056        assert_eq!(
2057            cache_binary_path(&build_cache.directory),
2058            PathBuf::from("/mnt/fast/mbx-cache/.mjolnir/bin/mbx")
2059        );
2060        assert_eq!(
2061            mounts,
2062            vec![targets::AdditionalMount {
2063                source: PathBuf::from("/mnt/fast/mbx-cache"),
2064                destination: PathBuf::from("/mnt/fast/mbx-cache"),
2065                access: targets::MountAccess::Rw,
2066            }]
2067        );
2068    }
2069
2070    #[test]
2071    fn a_session_at_the_legacy_shared_workspace_runs_without_the_cache() {
2072        let _isolated = isolated();
2073        let mut answers = native_host();
2074        answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2075        let executor = ProbeExecutor::new(&answers);
2076        let mut mounts = Vec::new();
2077        assert_eq!(
2078            prepare(
2079                &podman(None),
2080                &session(None),
2081                Some(&bundle()),
2082                Some(&clone_cache()),
2083                &mut mounts,
2084                &executor,
2085            ),
2086            None
2087        );
2088        assert!(executor.ran().is_empty());
2089    }
2090
2091    #[test]
2092    fn a_resumed_session_uses_current_machine_policy_instead_of_its_saved_budget() {
2093        let _isolated = isolated();
2094        let executor = ProbeExecutor::new(&native_host());
2095        let mut record = session(Some("/workspace/session-1"));
2096        record.build_cache = Some(SessionBuildCache {
2097            host: "local".into(),
2098            directory: PathBuf::from("/mnt/fast/mbx-cache"),
2099            max_size: Some("1GB".into()),
2100            target_root: None,
2101        });
2102        let mut mounts = Vec::new();
2103        let build_cache =
2104            prepare(&podman(None), &record, None, None, &mut mounts, &executor).unwrap();
2105        assert_eq!(build_cache.max_size, None);
2106        assert_eq!(build_cache.directory, PathBuf::from("/mnt/fast/mbx-cache"));
2107        assert_eq!(mounts.len(), 1);
2108        assert!(
2109            executor
2110                .ran()
2111                .iter()
2112                .any(|line| line.contains(".mj-apply.lock"))
2113        );
2114    }
2115
2116    #[test]
2117    fn a_session_moved_to_another_host_resolves_its_build_cache_again() {
2118        let _isolated = isolated();
2119        let mut answers = native_host();
2120        answers.push(("cat-file -e HEAD:Cargo.toml", 0, ""));
2121        let executor = ProbeExecutor::new(&answers);
2122        let mut record = session(Some("/workspace/session-1"));
2123        record.build_cache = Some(SessionBuildCache {
2124            // The host the session was provisioned on, which the target below
2125            // is not.
2126            host: "ssh:dev@example.test".into(),
2127            directory: PathBuf::from("/mnt/fast/mbx-cache"),
2128            max_size: None,
2129            target_root: Some(PathBuf::from("/mnt/fast/mbx-targets")),
2130        });
2131        let mut mounts = Vec::new();
2132
2133        let build_cache = prepare(
2134            &podman(None),
2135            &record,
2136            Some(&bundle()),
2137            Some(&clone_cache()),
2138            &mut mounts,
2139            &executor,
2140        )
2141        .expect("the destination host qualifies on its own");
2142
2143        assert_eq!(build_cache.host, "local");
2144        assert_eq!(build_cache.directory, PathBuf::from("/mnt/fast/mbx-cache"));
2145        assert_eq!(build_cache.target_root, None);
2146        assert_eq!(
2147            mounts
2148                .iter()
2149                .map(|mount| mount.destination.clone())
2150                .collect::<Vec<_>>(),
2151            vec![PathBuf::from("/mnt/fast/mbx-cache")]
2152        );
2153        assert!(
2154            executor
2155                .ran()
2156                .iter()
2157                .any(|line| line.contains("mj-reflink"))
2158        );
2159    }
2160
2161    #[test]
2162    fn an_attached_directory_over_the_cache_wins() {
2163        let build_cache = SessionBuildCache {
2164            host: "local-podman".into(),
2165            directory: PathBuf::from("/mnt/fast/mbx-cache"),
2166            max_size: None,
2167            target_root: None,
2168        };
2169        let mut mounts = vec![targets::AdditionalMount {
2170            source: PathBuf::from("/elsewhere"),
2171            destination: PathBuf::from("/mnt/fast/mbx-cache/actions"),
2172            access: targets::MountAccess::Ro,
2173        }];
2174        assert!(!attach_mounts(&build_cache, &mut mounts));
2175        assert_eq!(mounts.len(), 1);
2176    }
2177
2178    #[test]
2179    fn versions_compare_by_release_order() {
2180        let native = |version: &str| NativeMbx {
2181            program: "/usr/local/bin/mbx".into(),
2182            version: version.into(),
2183        };
2184        assert!(matches!(
2185            classify_native_mbx(Some(native(MBX_VERSION))),
2186            NativeMbxStatus::Compatible(_)
2187        ));
2188        assert!(matches!(
2189            classify_native_mbx(Some(native("1.23.0"))),
2190            NativeMbxStatus::Compatible(_)
2191        ));
2192        assert!(matches!(
2193            classify_native_mbx(Some(native("1.15.0"))),
2194            NativeMbxStatus::TooOld(_)
2195        ));
2196        assert!(matches!(
2197            classify_native_mbx(Some(native("not-a-version"))),
2198            NativeMbxStatus::Unknown { .. }
2199        ));
2200        assert!(matches!(classify_native_mbx(None), NativeMbxStatus::Absent));
2201    }
2202
2203    #[test]
2204    fn available_bytes_reads_the_df_available_column() {
2205        assert_eq!(
2206            available_bytes(
2207                "Filesystem 1K-blocks Used Available Capacity Mounted on\n\
2208                 /dev/sda1 1000 400 600 40% /\n"
2209            ),
2210            Some(600)
2211        );
2212        assert_eq!(available_bytes("Filesystem 1K-blocks\n"), None);
2213    }
2214
2215    #[cfg(target_os = "linux")]
2216    #[test]
2217    fn native_probe_prefers_path_then_user_bins_and_canonicalizes_symlinks() {
2218        use std::os::unix::fs::{PermissionsExt, symlink};
2219
2220        let root = tempfile::tempdir().unwrap();
2221        let path_bin = root.path().join("path-bin");
2222        let home = root.path().join("home");
2223        let local_bin = home.join(".local/bin");
2224        let cargo_bin = home.join(".cargo/bin");
2225        let real_bin = root.path().join("real");
2226        for directory in [&path_bin, &local_bin, &cargo_bin, &real_bin] {
2227            std::fs::create_dir_all(directory).unwrap();
2228        }
2229        let install = |path: &Path, version: &str| {
2230            std::fs::write(path, format!("#!/bin/sh\nprintf 'mbx {version}\\n'\n")).unwrap();
2231            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2232        };
2233        let real_mbx = real_bin.join("mbx-real");
2234        install(&real_mbx, "1.30.0");
2235        symlink(&real_mbx, path_bin.join("mbx")).unwrap();
2236        install(&local_bin.join("mbx"), "1.31.0");
2237        install(&cargo_bin.join("mbx"), "1.32.0");
2238        let readlink = std::env::split_paths(&std::env::var_os("PATH").unwrap())
2239            .map(|directory| directory.join("readlink"))
2240            .find(|candidate| candidate.is_file())
2241            .expect("readlink utility is available");
2242        symlink(readlink, path_bin.join("readlink")).unwrap();
2243
2244        let mut command = CommandSpec::new("/bin/sh", ["-c", NATIVE_VERSION_SCRIPT])
2245            .purpose("test native mbx path resolution");
2246        command.clear_env = true;
2247        command
2248            .env
2249            .insert("PATH".into(), path_bin.display().to_string());
2250        command
2251            .env
2252            .insert("HOME".into(), home.display().to_string());
2253        let executor = targets::ProcessExecutor;
2254
2255        let output = executor.execute(&command).unwrap();
2256        assert_eq!(output.status, 0);
2257        assert_eq!(
2258            parse_native_probe_output(&output.stdout).unwrap(),
2259            NativeMbx {
2260                program: real_mbx.clone(),
2261                version: "1.30.0".into(),
2262            }
2263        );
2264
2265        std::fs::remove_file(path_bin.join("mbx")).unwrap();
2266        let output = executor.execute(&command).unwrap();
2267        assert_eq!(output.status, 0);
2268        assert_eq!(
2269            parse_native_probe_output(&output.stdout).unwrap().program,
2270            local_bin.join("mbx")
2271        );
2272
2273        std::fs::remove_file(local_bin.join("mbx")).unwrap();
2274        let output = executor.execute(&command).unwrap();
2275        assert_eq!(output.status, 0);
2276        assert_eq!(
2277            parse_native_probe_output(&output.stdout).unwrap().program,
2278            cargo_bin.join("mbx")
2279        );
2280    }
2281
2282    #[cfg(target_os = "linux")]
2283    #[test]
2284    fn cache_copy_refresh_is_atomic_and_skips_unchanged_binaries() {
2285        use std::os::unix::fs::{MetadataExt, PermissionsExt};
2286
2287        let root = tempfile::tempdir().unwrap();
2288        let source = root.path().join("native mbx");
2289        let native_bin = root.path().join("native-bin");
2290        let home = root.path().join("home");
2291        let cache = root.path().join("cache with spaces");
2292        std::fs::create_dir_all(&native_bin).unwrap();
2293        std::fs::create_dir_all(&home).unwrap();
2294        let install = |version: &str| {
2295            std::fs::write(&source, format!("#!/bin/sh\nprintf 'mbx {version}\\n'\n")).unwrap();
2296            std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o755)).unwrap();
2297        };
2298        let native = |version: &str| NativeMbx {
2299            program: source.clone(),
2300            version: version.to_owned(),
2301        };
2302        std::os::unix::fs::symlink(&source, native_bin.join("mbx")).unwrap();
2303        struct IsolatedHostExecutor {
2304            path: String,
2305            home: String,
2306        }
2307        impl CommandExecutor for IsolatedHostExecutor {
2308            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2309                let mut command = command.clone();
2310                command.env.insert("PATH".into(), self.path.clone());
2311                command.env.insert("HOME".into(), self.home.clone());
2312                targets::ProcessExecutor.execute(&command)
2313            }
2314        }
2315        let executor = IsolatedHostExecutor {
2316            path: format!("{}:/usr/bin:/bin", native_bin.display()),
2317            home: home.display().to_string(),
2318        };
2319
2320        install("1.22.0");
2321        let first =
2322            sync_mbx_binary_from_native(&CacheHost::Local, &native("1.22.0"), &cache, &executor)
2323                .unwrap();
2324        let copy = first.path;
2325        assert_eq!(copy, cache.join(".mjolnir/bin/mbx"));
2326        assert_eq!(
2327            std::fs::read(&copy).unwrap(),
2328            std::fs::read(&source).unwrap()
2329        );
2330        assert_eq!(
2331            std::fs::metadata(&copy).unwrap().permissions().mode() & 0o777,
2332            0o755
2333        );
2334        let original_inode = std::fs::metadata(&copy).unwrap().ino();
2335
2336        let unchanged =
2337            sync_mbx_binary_from_native(&CacheHost::Local, &native("1.22.0"), &cache, &executor)
2338                .unwrap();
2339        assert_eq!(unchanged.path, copy);
2340        assert_eq!(std::fs::metadata(&copy).unwrap().ino(), original_inode);
2341
2342        install("1.23.0");
2343        sync_mbx_binary_from_native(&CacheHost::Local, &native("1.23.0"), &cache, &executor)
2344            .unwrap();
2345        assert_ne!(std::fs::metadata(&copy).unwrap().ino(), original_inode);
2346        let version = executor
2347            .execute(&CommandSpec::new(
2348                copy.to_string_lossy().into_owned(),
2349                ["--version"],
2350            ))
2351            .unwrap();
2352        assert_eq!(
2353            String::from_utf8_lossy(&version.stdout).trim(),
2354            "mbx 1.23.0"
2355        );
2356        assert_eq!(
2357            std::fs::read_dir(cache.join(".mjolnir/bin"))
2358                .unwrap()
2359                .count(),
2360            2,
2361            "publication leaves only the copy and its cross-process lock file"
2362        );
2363    }
2364
2365    #[test]
2366    fn cache_sync_reprobes_and_retries_after_another_process_changes_the_host_binary() {
2367        struct ReprobeExecutor {
2368            syncs: std::sync::atomic::AtomicUsize,
2369            commands: Mutex<Vec<CommandSpec>>,
2370        }
2371
2372        impl CommandExecutor for ReprobeExecutor {
2373            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2374                self.commands.lock().unwrap().push(command.clone());
2375                let (status, stdout, stderr) = match command.purpose.as_str() {
2376                    "synchronize native mbx into the shared cache"
2377                        if self.syncs.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 =>
2378                    {
2379                        (75, Vec::new(), b"native mbx changed".to_vec())
2380                    }
2381                    "synchronize native mbx into the shared cache" => {
2382                        (0, b"synced\n".to_vec(), Vec::new())
2383                    }
2384                    "read the container host mbx version" => {
2385                        (0, b"/opt/mbx/current\nmbx 1.23.0".to_vec(), Vec::new())
2386                    }
2387                    purpose => bail!("unexpected command purpose {purpose}"),
2388                };
2389                Ok(CommandOutput {
2390                    status,
2391                    stdout,
2392                    stderr,
2393                })
2394            }
2395        }
2396
2397        let directory = PathBuf::from("/tmp/mjolnir-mbx-retry");
2398        let executor = ReprobeExecutor {
2399            syncs: std::sync::atomic::AtomicUsize::new(0),
2400            commands: Mutex::new(Vec::new()),
2401        };
2402        let binary = sync_mbx_binary_from_native(
2403            &CacheHost::Local,
2404            &NativeMbx {
2405                program: PathBuf::from("/opt/mbx/old"),
2406                version: "1.22.0".into(),
2407            },
2408            &directory,
2409            &executor,
2410        )
2411        .unwrap();
2412
2413        assert_eq!(binary.version, "1.23.0");
2414        assert_eq!(
2415            executor
2416                .commands
2417                .lock()
2418                .unwrap()
2419                .iter()
2420                .map(|command| command.purpose.as_str())
2421                .collect::<Vec<_>>(),
2422            [
2423                "synchronize native mbx into the shared cache",
2424                "read the container host mbx version",
2425                "synchronize native mbx into the shared cache",
2426            ]
2427        );
2428    }
2429
2430    #[cfg(target_os = "linux")]
2431    #[test]
2432    fn cache_sync_without_flock_rechecks_after_rename_and_resynchronizes() {
2433        use std::os::unix::fs::{PermissionsExt, symlink};
2434
2435        let root = tempfile::tempdir().unwrap();
2436        let tools = root.path().join("tools");
2437        let home = root.path().join("home");
2438        let cache = root.path().join("cache");
2439        let old_source = root.path().join("mbx-1.22.0");
2440        let new_source = root.path().join("mbx-1.23.0");
2441        let native_path = tools.join("mbx");
2442        let flipped = root.path().join("flipped");
2443        std::fs::create_dir_all(&tools).unwrap();
2444        std::fs::create_dir_all(&home).unwrap();
2445
2446        for name in [
2447            "sh", "dirname", "mkdir", "readlink", "mktemp", "chmod", "wc", "mv", "rm", "ln",
2448        ] {
2449            let executable = std::env::split_paths(&std::env::var_os("PATH").unwrap())
2450                .map(|directory| directory.join(name))
2451                .find(|candidate| candidate.is_file())
2452                .unwrap_or_else(|| panic!("could not find test utility {name}"));
2453            symlink(executable, tools.join(name)).unwrap();
2454        }
2455
2456        let real_cp = std::env::split_paths(&std::env::var_os("PATH").unwrap())
2457            .map(|directory| directory.join("cp"))
2458            .find(|candidate| candidate.is_file())
2459            .unwrap();
2460        let cp_wrapper = tools.join("cp");
2461        std::fs::write(
2462            &cp_wrapper,
2463            r#"#!/bin/sh
2464"$MBX_TEST_REAL_CP" "$@" || exit $?
2465if [ ! -e "$MBX_TEST_FLIPPED" ]; then
2466    : > "$MBX_TEST_FLIPPED"
2467    rm -f -- "$MBX_TEST_NATIVE"
2468    ln -s -- "$MBX_TEST_NEW_SOURCE" "$MBX_TEST_NATIVE"
2469fi"#,
2470        )
2471        .unwrap();
2472        std::fs::set_permissions(&cp_wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
2473        for (path, version) in [(&old_source, "1.22.0"), (&new_source, "1.23.0")] {
2474            std::fs::write(path, format!("#!/bin/sh\nprintf 'mbx {version}\\n'\n")).unwrap();
2475            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2476        }
2477        symlink(&old_source, &native_path).unwrap();
2478
2479        struct FallbackExecutor {
2480            tools: PathBuf,
2481            home: PathBuf,
2482            real_cp: PathBuf,
2483            flipped: PathBuf,
2484            native: PathBuf,
2485            new_source: PathBuf,
2486        }
2487        impl CommandExecutor for FallbackExecutor {
2488            fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2489                let mut command = command.clone();
2490                command
2491                    .env
2492                    .insert("PATH".into(), self.tools.display().to_string());
2493                command
2494                    .env
2495                    .insert("HOME".into(), self.home.display().to_string());
2496                command.env.insert(
2497                    "MBX_TEST_REAL_CP".into(),
2498                    self.real_cp.display().to_string(),
2499                );
2500                command.env.insert(
2501                    "MBX_TEST_FLIPPED".into(),
2502                    self.flipped.display().to_string(),
2503                );
2504                command
2505                    .env
2506                    .insert("MBX_TEST_NATIVE".into(), self.native.display().to_string());
2507                command.env.insert(
2508                    "MBX_TEST_NEW_SOURCE".into(),
2509                    self.new_source.display().to_string(),
2510                );
2511                targets::ProcessExecutor.execute(&command)
2512            }
2513        }
2514        let executor = FallbackExecutor {
2515            tools,
2516            home,
2517            real_cp,
2518            flipped: flipped.clone(),
2519            native: native_path,
2520            new_source,
2521        };
2522
2523        let binary = sync_mbx_binary_from_native(
2524            &CacheHost::Local,
2525            &NativeMbx {
2526                program: old_source,
2527                version: "1.22.0".into(),
2528            },
2529            &cache,
2530            &executor,
2531        )
2532        .unwrap();
2533
2534        assert!(
2535            flipped.exists(),
2536            "the test copy did not switch host versions"
2537        );
2538        assert_eq!(binary.version, "1.23.0");
2539        let copied = targets::ProcessExecutor
2540            .execute(&CommandSpec::new(
2541                binary.path.to_string_lossy().into_owned(),
2542                ["--version"],
2543            ))
2544            .unwrap();
2545        assert_eq!(String::from_utf8_lossy(&copied.stdout).trim(), "mbx 1.23.0");
2546    }
2547
2548    #[cfg(target_os = "linux")]
2549    #[test]
2550    fn cross_process_cache_sync_cannot_publish_a_stale_host_binary_last() {
2551        use std::os::unix::fs::{PermissionsExt, symlink};
2552        use std::sync::atomic::{AtomicBool, Ordering};
2553
2554        let root = tempfile::tempdir().unwrap();
2555        let source_dir = root.path().join("sources");
2556        let native_bin = root.path().join("native-bin");
2557        let wrapper_bin = root.path().join("wrapper-bin");
2558        let home = root.path().join("home");
2559        let cache = root.path().join("cache");
2560        let lock = cache.join(".mjolnir/bin/.mbx.lock");
2561        let ready = root.path().join("lock-ready");
2562        let release = root.path().join("release-lock");
2563        std::fs::create_dir_all(&source_dir).unwrap();
2564        std::fs::create_dir_all(&native_bin).unwrap();
2565        std::fs::create_dir_all(&wrapper_bin).unwrap();
2566        std::fs::create_dir_all(&home).unwrap();
2567        std::fs::create_dir_all(lock.parent().unwrap()).unwrap();
2568
2569        let old_source = source_dir.join("mbx-1.22.0");
2570        let new_source = source_dir.join("mbx-1.23.0");
2571        for (path, version) in [(&old_source, "1.22.0"), (&new_source, "1.23.0")] {
2572            std::fs::write(path, format!("#!/bin/sh\nprintf 'mbx {version}\\n'\n")).unwrap();
2573            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2574        }
2575        let native_path = native_bin.join("mbx");
2576        symlink(&old_source, &native_path).unwrap();
2577
2578        // Mark the old process as soon as it reaches flock, then delegate to
2579        // the real utility. This proves it is waiting on the held lock before
2580        // the native install path changes.
2581        let flock_wrapper = wrapper_bin.join("flock");
2582        std::fs::write(
2583            &flock_wrapper,
2584            "#!/bin/sh\n[ -z \"${MBX_TEST_FLOCK_MARKER:-}\" ] || : > \"$MBX_TEST_FLOCK_MARKER\"\nexec /usr/bin/flock \"$@\"\n",
2585        )
2586        .unwrap();
2587        std::fs::set_permissions(&flock_wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
2588
2589        let path_value = format!(
2590            "{}:{}:/usr/bin:/bin",
2591            wrapper_bin.display(),
2592            native_bin.display()
2593        );
2594        let executor = crate::targets::ProcessExecutor;
2595        let holder_script = r#"set -eu
2596exec 9>"$1"
2597flock -x 9
2598: > "$2"
2599while [ ! -e "$3" ]; do sleep 0.01; done"#;
2600        let mut holder = CommandSpec::new(
2601            "sh",
2602            vec![
2603                "-c".into(),
2604                holder_script.into(),
2605                "mbx-lock-holder".into(),
2606                lock.to_string_lossy().into_owned(),
2607                ready.to_string_lossy().into_owned(),
2608                release.to_string_lossy().into_owned(),
2609            ],
2610        )
2611        .purpose("hold test mbx lock");
2612        holder.env.insert("PATH".into(), path_value.clone());
2613        holder.env.insert("HOME".into(), home.display().to_string());
2614        let holder_thread = std::thread::spawn(move || executor.execute(&holder));
2615
2616        let deadline = std::time::Instant::now() + Duration::from_secs(3);
2617        while !ready.exists() && std::time::Instant::now() < deadline {
2618            std::thread::sleep(Duration::from_millis(10));
2619        }
2620        if !ready.exists() {
2621            std::fs::write(&release, "release").unwrap();
2622            let _ = holder_thread.join();
2623            panic!("the test lock holder did not acquire flock");
2624        }
2625
2626        let make_sync = |source: &Path, version: &str| {
2627            let mut command = CommandSpec::new(
2628                "sh",
2629                [
2630                    "-c".to_owned(),
2631                    SYNC_MBX_BINARY_SCRIPT.to_owned(),
2632                    "test-mbx-sync".to_owned(),
2633                    source.to_string_lossy().into_owned(),
2634                    cache_binary_path(&cache).to_string_lossy().into_owned(),
2635                    version.to_owned(),
2636                    NATIVE_VERSION_SCRIPT.to_owned(),
2637                ],
2638            )
2639            .purpose("run cross-process mbx sync test");
2640            command.clear_env = true;
2641            command.env.insert("PATH".into(), path_value.clone());
2642            command
2643                .env
2644                .insert("HOME".into(), home.display().to_string());
2645            command
2646        };
2647
2648        let old_marker = root.path().join("old-reached-flock");
2649        let old_command = make_sync(&old_source, "1.22.0");
2650        let old_executor = crate::targets::ProcessExecutor;
2651        let old_done = std::sync::Arc::new(AtomicBool::new(false));
2652        let old_done_thread = old_done.clone();
2653        let mut old_command = old_command;
2654        old_command.env.insert(
2655            "MBX_TEST_FLOCK_MARKER".into(),
2656            old_marker.display().to_string(),
2657        );
2658        let old_thread = std::thread::spawn(move || {
2659            let result = old_executor.execute(&old_command);
2660            old_done_thread.store(true, Ordering::SeqCst);
2661            result
2662        });
2663        let deadline = std::time::Instant::now() + Duration::from_secs(3);
2664        while !old_marker.exists() && std::time::Instant::now() < deadline {
2665            std::thread::sleep(Duration::from_millis(10));
2666        }
2667        let old_waited = old_marker.exists() && !old_done.load(Ordering::SeqCst);
2668
2669        std::fs::remove_file(&native_path).unwrap();
2670        symlink(&new_source, &native_path).unwrap();
2671        let new_command = make_sync(&new_source, "1.23.0");
2672        let new_executor = crate::targets::ProcessExecutor;
2673        let new_thread = std::thread::spawn(move || new_executor.execute(&new_command));
2674        std::thread::sleep(Duration::from_millis(40));
2675        std::fs::write(&release, "release").unwrap();
2676
2677        let holder_output = holder_thread.join().unwrap().unwrap();
2678        let old_output = old_thread.join().unwrap().unwrap();
2679        let new_output = new_thread.join().unwrap().unwrap();
2680        assert_eq!(holder_output.status, 0, "{holder_output:?}");
2681        assert!(old_waited, "the stale synchronizer did not wait on flock");
2682        assert_eq!(old_output.status, 75, "{old_output:?}");
2683        assert_eq!(new_output.status, 0, "{new_output:?}");
2684
2685        let version = crate::targets::ProcessExecutor
2686            .execute(&CommandSpec::new(
2687                cache_binary_path(&cache).to_string_lossy().into_owned(),
2688                ["--version"],
2689            ))
2690            .unwrap();
2691        assert_eq!(
2692            String::from_utf8_lossy(&version.stdout).trim(),
2693            "mbx 1.23.0"
2694        );
2695    }
2696
2697    #[test]
2698    fn absent_or_too_old_native_mbx_leaves_the_existing_cache_copy() {
2699        struct ProbeAnswer {
2700            status: i32,
2701            stdout: Vec<u8>,
2702        }
2703
2704        impl CommandExecutor for ProbeAnswer {
2705            fn execute(&self, _command: &CommandSpec) -> Result<CommandOutput> {
2706                Ok(CommandOutput {
2707                    status: self.status,
2708                    stdout: self.stdout.clone(),
2709                    stderr: Vec::new(),
2710                })
2711            }
2712        }
2713
2714        let root = tempfile::tempdir().unwrap();
2715        let cache = root.path().join("cache");
2716        let copy = cache_binary_path(&cache);
2717        std::fs::create_dir_all(copy.parent().unwrap()).unwrap();
2718        std::fs::write(&copy, b"previous compatible copy").unwrap();
2719
2720        for answer in [
2721            ProbeAnswer {
2722                status: 1,
2723                stdout: Vec::new(),
2724            },
2725            ProbeAnswer {
2726                status: 0,
2727                stdout: "/opt/old/mbx\nmbx 1.21.0".into(),
2728            },
2729        ] {
2730            let result = sync_current_mbx_binary(&CacheHost::Local, &cache, &answer).unwrap();
2731            assert!(matches!(result, CachedMbxSync::Unavailable(_)));
2732            assert_eq!(std::fs::read(&copy).unwrap(), b"previous compatible copy");
2733        }
2734    }
2735
2736    #[test]
2737    fn the_preview_reports_native_cache_values_without_creating_directories() {
2738        let _isolated = isolated();
2739        let executor = ProbeExecutor::new(&native_host());
2740        let preview = preview_build_cache(&configured_local_machine(), &executor)
2741            .unwrap()
2742            .unwrap();
2743        assert_eq!(preview.native_mbx.as_deref(), Some(MBX_VERSION));
2744        assert_eq!(preview.mbx_profile_file, None);
2745        assert_eq!(
2746            preview.directory,
2747            Some(PathBuf::from("/mnt/fast/mbx-cache"))
2748        );
2749        assert_eq!(
2750            preview.max_total_size,
2751            Some(BuildCacheLimit::MbxDefault(None))
2752        );
2753        assert_eq!(preview.off_reason, None);
2754        // Profile previews carry the shared script, including its update-only
2755        // mkdir branch, but do not execute a host mkdir command.
2756        assert!(!executor.ran().iter().any(|line| line.starts_with("mkdir ")));
2757    }
2758}