Skip to main content

mj_controller/controller/
mbx.rs

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