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