Skip to main content

mbx_cache_core/
agent.rs

1use crate::uploads::{ConnectionUploads, UploadQueue, UploadSink};
2use crate::{
3    ActionPrediction, ActionPromiseCompletion, ActionPromiseState, BlobPackLimits, CacheDigest,
4    CacheDirectory, LocalActionCache, LocalCas, MAX_STAGED_BLOB_PACK_BYTES,
5    MAX_STAGED_BLOB_PACK_ITEMS, ManifestPutOutcome, RemoteActionResult, RemoteCacheClient,
6    RemoteCacheMode, RustcMetadata, TaskActionManifest, blob_pack_chunk, canonical_json,
7};
8use eyre::{Context, Result, bail};
9use futures_util::{FutureExt, StreamExt, future::BoxFuture, stream};
10use log::warn;
11use std::collections::{BTreeMap, BTreeSet};
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::{Arc, Mutex, Weak};
16use std::time::{Duration, Instant, SystemTime};
17
18/// Fleet claims are leases rather than correctness locks. This only bounds how
19/// long one shim waits through repeated pending responses before degrading to
20/// an ordinary compilation; the server independently expires abandoned claims.
21const ACTION_PROMISE_WAIT: Duration = Duration::from_secs(60 * 60);
22use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
23
24mod file_digest;
25mod manifest;
26mod prefetch;
27mod stats;
28mod wire;
29
30#[cfg(test)]
31pub(crate) use prefetch::select_prefetch_actions;
32
33pub use file_digest::{
34    FileDigestCache, FileDigestScope, FileIdentity, NoFileDigestCache, RecordedFileDigest,
35};
36pub use manifest::{is_task_identity, task_manifest_actions};
37use manifest::{
38    merge_remote_task_manifest, merge_task_manifests, task_manifest_dir, validate_task_identity,
39    validate_task_manifest,
40};
41pub use stats::{AgentStats, CompilerStats};
42use wire::MAX_REQUEST_BYTES;
43pub use wire::{
44    AGENT_PROTOCOL_VERSION, AgentEvent, AgentEventObserver, AgentRequest, AgentResponse,
45    RestoreStats,
46};
47
48const MAX_EXECUTABLE_IDENTITIES: usize = 64;
49const MAX_EXECUTABLE_IDENTITY_SIZE: usize = 64 * 1024;
50const MAX_EXECUTABLE_IDENTITY_BYTES: usize = 256 * 1024;
51const TASK_ACTION_MANIFEST_VERSION: u8 = 1;
52const MAX_TASK_ACTION_PREDICTIONS: usize = 16 * 1024;
53/// Longest shim diagnostic the agent accepts, in bytes.
54const MAX_WARNING_BYTES: usize = 4 * 1024;
55/// Most distinct shim diagnostics one session surfaces before going quiet.
56///
57/// A failure mode that repeats across a build tends to repeat with the same
58/// message, which deduplication already collapses; the cap only guards
59/// against a message that embeds something unique per compilation.
60const MAX_WARNINGS: usize = 128;
61/// Most file identities one digest lookup or record may carry.
62const MAX_FILE_DIGEST_BATCH: usize = 16 * 1024;
63/// Most recorded file digests one session retains across every scope.
64///
65/// Roughly two orders of magnitude above the largest workspace measured; the
66/// cap exists so a pathological build bounds the agent instead of growing it.
67const MAX_FILE_DIGEST_ENTRIES: usize = 1024 * 1024;
68const MAX_REMOTE_TRANSFERS: usize = 64;
69const MAX_PREFETCH_TRANSFERS: usize = 48;
70/// Most predicted actions whose complete output closures are downloaded
71/// speculatively for one task.
72///
73/// Manifests deliberately retain predictions across compatible builds, so a
74/// large workspace can describe far more actions than the next invocation
75/// will request. Keep the most expensive recorded actions warm and let
76/// foreground lookups fetch the long tail on demand. A 256-action ceiling also
77/// bounds speculative output transfer: real Cargo manifests commonly contain
78/// predictions for build-script work made unnecessary by an earlier cache hit.
79const MAX_PREFETCH_ACTIONS: usize = 256;
80// Resolve speculative actions progressively so the most valuable predictions
81// become usable first and cancellation at the end of a build abandons a small
82// tail instead of one workspace-sized transfer wave. Action-result lookup is
83// still batched independently; this only bounds each output-closure download.
84const MAX_PREFETCH_ACTION_WAVE: usize = 32;
85/// Batched action lookups issued at once.
86///
87/// One request already asks about hundreds of actions. Serial batches keep a
88/// fleet of concurrent CI jobs from multiplying metadata pressure while blob
89/// transfers from earlier answers are also underway.
90const MAX_PREFETCH_BATCH_LOOKUPS: usize = 1;
91const PREFETCH_ACTION_BATCH_DELAY: Duration = Duration::from_millis(5);
92const MAX_PREFETCH_DIRECTORY_OBJECTS: usize = 100_000;
93const MAX_PREFETCH_OBJECTS_PER_WAVE: usize = 100_000;
94const DEFAULT_MAX_REMOTE_DOWNLOAD_BYTES: u64 = 5 * 1024 * 1024 * 1024;
95
96/// Remote action-cache access owned by one task session.
97pub struct AgentRemoteCache {
98    /// Remote protocol client used by the agent.
99    pub client: RemoteCacheClient,
100    /// Permitted remote read/write operations.
101    pub mode: RemoteCacheMode,
102    /// Directory used for verified downloads before CAS ingestion.
103    pub staging_dir: PathBuf,
104}
105
106#[derive(Default)]
107struct AtomicAgentStats {
108    lookups: AtomicU64,
109    unconsulted: AtomicU64,
110    hits: AtomicU64,
111    stores: AtomicU64,
112    stored_bytes: AtomicU64,
113    verifications: AtomicU64,
114    divergences: AtomicU64,
115    downloaded_bytes: AtomicU64,
116    uploaded_bytes: AtomicU64,
117    background_uploads: AtomicU64,
118    background_upload_failures: AtomicU64,
119    remote_blob_pack_uploads: AtomicU64,
120    remote_blob_pack_upload_blobs: AtomicU64,
121    upload_drain_duration_ns: AtomicU64,
122    prefetched_actions: AtomicU64,
123    predictions_loaded: AtomicU64,
124    remote_failures: AtomicU64,
125    remote_manifest_lookups: AtomicU64,
126    remote_manifest_lookup_duration_ns: AtomicU64,
127    remote_action_lookups: AtomicU64,
128    remote_action_lookup_duration_ns: AtomicU64,
129    remote_blob_requests: AtomicU64,
130    remote_blob_pack_requests: AtomicU64,
131    remote_blob_pack_blobs: AtomicU64,
132    remote_blob_transfer_duration_ns: AtomicU64,
133    local_cas_write_duration_ns: AtomicU64,
134    prefetch_runs: AtomicU64,
135    prefetch_duration_ns: AtomicU64,
136    materialization_duration_ns: AtomicU64,
137    bypasses: Mutex<BTreeMap<String, u64>>,
138    avoided_compiler_duration_ns: AtomicU64,
139    compiler: Mutex<BTreeMap<String, CompilerStats>>,
140    slow_compilations: Mutex<BTreeMap<String, u64>>,
141    restored_output_files: AtomicU64,
142    restored_output_bytes: AtomicU64,
143    reflinked_output_files: AtomicU64,
144    reflinked_output_bytes: AtomicU64,
145    copied_output_files: AtomicU64,
146    copied_output_bytes: AtomicU64,
147    reused_output_files: AtomicU64,
148    reused_output_bytes: AtomicU64,
149}
150
151struct AtomicDurationTimer<'a> {
152    started: Instant,
153    target: &'a AtomicU64,
154}
155
156impl<'a> AtomicDurationTimer<'a> {
157    fn start(target: &'a AtomicU64) -> Self {
158        Self {
159            started: Instant::now(),
160            target,
161        }
162    }
163}
164
165impl Drop for AtomicDurationTimer<'_> {
166    fn drop(&mut self) {
167        atomic_saturating_add(self.target, duration_ns(self.started));
168    }
169}
170
171fn duration_ns(started: Instant) -> u64 {
172    started.elapsed().as_nanos().try_into().unwrap_or(u64::MAX)
173}
174
175fn validate_crate_name(crate_name: Option<&str>) -> Result<()> {
176    if let Some(crate_name) = crate_name
177        && (crate_name.len() > 256 || crate_name.contains(['\0', '\n', '\r']))
178    {
179        bail!("invalid compiler crate name");
180    }
181    Ok(())
182}
183
184fn atomic_saturating_add(target: &AtomicU64, value: u64) {
185    let _ = target.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
186        Some(current.saturating_add(value))
187    });
188}
189
190fn queue_prefetch_digest(
191    verified: &BTreeMap<CacheDigest, PathBuf>,
192    pending: &mut BTreeMap<CacheDigest, ()>,
193    digest: CacheDigest,
194) {
195    if verified.contains_key(&digest) || pending.contains_key(&digest) {
196        return;
197    }
198    pending.insert(digest, ());
199}
200
201fn queue_prefetch_directory(
202    seen: &BTreeMap<CacheDigest, ()>,
203    pending: &mut BTreeMap<CacheDigest, ()>,
204    digest: CacheDigest,
205    limit: usize,
206) -> bool {
207    if seen.contains_key(&digest) || pending.contains_key(&digest) {
208        return true;
209    }
210    if seen.len().saturating_add(pending.len()) >= limit {
211        return false;
212    }
213    pending.insert(digest, ());
214    true
215}
216
217/// Shared state for an agent hosted by the process that owns a build session.
218///
219/// Transport listeners deliberately live in the embedder so the session
220/// lifecycle owns them. This type only contains ecosystem-independent CAS and
221/// protocol logic.
222#[derive(Clone)]
223pub struct CacheAgent {
224    cas: LocalCas,
225    actions: LocalActionCache,
226    verified_blobs: Arc<Mutex<BTreeMap<CacheDigest, VerifiedBlob>>>,
227    version: Arc<str>,
228    write_locks: Arc<Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>>,
229    action_locks: Arc<Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>>,
230    stats: Arc<AtomicAgentStats>,
231    observer: Option<Arc<dyn AgentEventObserver>>,
232    executable_identities: Arc<Mutex<BTreeMap<ExecutableIdentityKey, Vec<u8>>>>,
233    manifest_dir: Arc<PathBuf>,
234    task_actions: Arc<Mutex<BTreeMap<String, TaskActionState>>>,
235    next_task_run: Arc<AtomicU64>,
236    manifest_write_lock: Arc<Mutex<()>>,
237    remote: Option<Arc<RemoteCacheClient>>,
238    remote_mode: RemoteCacheMode,
239    remote_staging_dir: Arc<PathBuf>,
240    remote_download_limit: u64,
241    remote_download_bytes: Arc<AtomicU64>,
242    pending_remote_actions: Arc<Mutex<BTreeMap<CacheDigest, RemoteActionResult>>>,
243    remote_transfers: Arc<tokio::sync::Semaphore>,
244    prefetch_transfers: Arc<tokio::sync::Semaphore>,
245    prefetch_tasks: Arc<Mutex<Vec<tokio::task::JoinHandle<()>>>>,
246    /// Distinct shim diagnostics already surfaced, so a warning that fires
247    /// once per compilation is printed once per session.
248    warnings: Arc<Mutex<BTreeSet<String>>>,
249    /// Digests of files shims hashed or wrote this session, keyed by scope and
250    /// path, each entry standing while its recorded identity matches the disk.
251    file_digests: Arc<Mutex<BTreeMap<(FileDigestScope, PathBuf), RecordedFileDigest>>>,
252    /// Deferred remote publication, present only when the session may write.
253    uploads: Option<UploadQueue>,
254}
255
256/// Records background upload activity against a session's statistics.
257struct AgentUploadSink {
258    stats: Arc<AtomicAgentStats>,
259}
260
261impl UploadSink for AgentUploadSink {
262    fn record_blob_uploaded(&self, bytes: u64) {
263        self.stats
264            .background_uploads
265            .fetch_add(1, Ordering::Relaxed);
266        self.stats
267            .uploaded_bytes
268            .fetch_add(bytes, Ordering::Relaxed);
269    }
270
271    fn record_action_uploaded(&self) {
272        self.stats
273            .background_uploads
274            .fetch_add(1, Ordering::Relaxed);
275    }
276
277    fn record_blob_pack_uploaded(&self, blobs: u64) {
278        self.stats
279            .remote_blob_pack_uploads
280            .fetch_add(1, Ordering::Relaxed);
281        self.stats
282            .remote_blob_pack_upload_blobs
283            .fetch_add(blobs, Ordering::Relaxed);
284    }
285
286    fn record_upload_failure(&self) {
287        self.stats
288            .background_upload_failures
289            .fetch_add(1, Ordering::Relaxed);
290        self.stats.remote_failures.fetch_add(1, Ordering::Relaxed);
291    }
292}
293
294/// A CAS blob whose contents this session hashed, and the file identity that
295/// says the hash still describes what is on disk.
296///
297/// Length alone would miss an overwrite that keeps the size; the modification
298/// time is what makes a rewrite visible without reading the bytes again.
299#[derive(Debug, Clone)]
300struct VerifiedBlob {
301    path: PathBuf,
302    len: u64,
303    modified: SystemTime,
304}
305
306impl VerifiedBlob {
307    /// Describe a file that was just verified, or nothing when the filesystem
308    /// will not report a modification time to compare against later.
309    fn describe(path: &Path) -> Option<Self> {
310        let metadata = std::fs::metadata(path).ok()?;
311        Some(Self {
312            path: path.to_path_buf(),
313            len: metadata.len(),
314            modified: metadata.modified().ok()?,
315        })
316    }
317
318    /// Whether the file still has the identity it had when it was verified.
319    fn is_unchanged(&self) -> bool {
320        let Ok(metadata) = std::fs::metadata(&self.path) else {
321            return false;
322        };
323        metadata.len() == self.len && metadata.modified().is_ok_and(|now| now == self.modified)
324    }
325}
326
327#[derive(Debug, Clone, Default)]
328struct TaskActionState {
329    manifest: String,
330    baseline_loaded: bool,
331    predictions: BTreeMap<CacheDigest, ActionPrediction>,
332    pending_predictions: BTreeMap<CacheDigest, ActionPrediction>,
333    remote_etag: Option<String>,
334}
335
336struct PrefetchedAction {
337    adapter: String,
338    result: RemoteActionResult,
339}
340
341struct RemoteDownloadReservation {
342    counter: Arc<AtomicU64>,
343    reserved: u64,
344    committed: bool,
345}
346
347impl RemoteDownloadReservation {
348    fn bytes(&self) -> u64 {
349        self.reserved
350    }
351
352    fn commit(mut self, bytes: u64) {
353        debug_assert!(bytes <= self.reserved);
354        self.counter
355            .fetch_sub(self.reserved.saturating_sub(bytes), Ordering::AcqRel);
356        self.committed = true;
357    }
358}
359
360impl Drop for RemoteDownloadReservation {
361    fn drop(&mut self) {
362        if !self.committed {
363            self.counter.fetch_sub(self.reserved, Ordering::AcqRel);
364        }
365    }
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
369struct ExecutableIdentityKey {
370    executable: PathBuf,
371    environment: BTreeMap<String, Option<String>>,
372}
373
374impl CacheAgent {
375    /// Create an agent backed by the cache rooted at `cache_dir`.
376    pub fn new(cache_dir: impl Into<PathBuf>, version: impl Into<Arc<str>>) -> Self {
377        Self::build(cache_dir.into(), version.into(), None, 0)
378    }
379
380    /// Create an agent with local-first access to a remote action cache.
381    pub fn new_remote(
382        cache_dir: impl Into<PathBuf>,
383        version: impl Into<Arc<str>>,
384        remote: AgentRemoteCache,
385    ) -> Self {
386        Self::build(
387            cache_dir.into(),
388            version.into(),
389            Some(remote),
390            DEFAULT_MAX_REMOTE_DOWNLOAD_BYTES,
391        )
392    }
393
394    /// Create a remote agent with a cumulative download budget for the session.
395    pub fn new_remote_with_download_limit(
396        cache_dir: impl Into<PathBuf>,
397        version: impl Into<Arc<str>>,
398        remote: AgentRemoteCache,
399        max_remote_download_bytes: u64,
400    ) -> Self {
401        Self::build(
402            cache_dir.into(),
403            version.into(),
404            Some(remote),
405            max_remote_download_bytes,
406        )
407    }
408
409    fn build(
410        cache_dir: PathBuf,
411        version: Arc<str>,
412        remote: Option<AgentRemoteCache>,
413        remote_download_limit: u64,
414    ) -> Self {
415        let remote_mode = remote
416            .as_ref()
417            .map_or(RemoteCacheMode::ReadOnly, |remote| remote.mode);
418        let remote_staging_dir = remote.as_ref().map_or_else(
419            || cache_dir.join("remote"),
420            |remote| remote.staging_dir.clone(),
421        );
422        let remote = remote.map(|remote| Arc::new(remote.client));
423        let stats = Arc::new(AtomicAgentStats::default());
424        let remote_transfers = Arc::new(tokio::sync::Semaphore::new(MAX_REMOTE_TRANSFERS));
425        // A session that cannot publish never queues an upload, so it does not
426        // need somewhere to queue one.
427        let uploads = remote
428            .clone()
429            .filter(|_| remote_mode.writes())
430            .map(|client| {
431                UploadQueue::new(
432                    client,
433                    Arc::new(AgentUploadSink {
434                        stats: stats.clone(),
435                    }),
436                    remote_transfers.clone(),
437                )
438            });
439        Self {
440            cas: LocalCas::new(cache_dir.clone()),
441            actions: LocalActionCache::new(cache_dir.clone()),
442            verified_blobs: Arc::new(Mutex::new(BTreeMap::new())),
443            version,
444            write_locks: Arc::new(Mutex::new(BTreeMap::new())),
445            action_locks: Arc::new(Mutex::new(BTreeMap::new())),
446            stats,
447            observer: None,
448            executable_identities: Arc::new(Mutex::new(BTreeMap::new())),
449            manifest_dir: Arc::new(task_manifest_dir(&cache_dir)),
450            task_actions: Arc::new(Mutex::new(BTreeMap::new())),
451            next_task_run: Arc::new(AtomicU64::new(0)),
452            manifest_write_lock: Arc::new(Mutex::new(())),
453            remote,
454            remote_mode,
455            remote_staging_dir: Arc::new(remote_staging_dir),
456            remote_download_limit,
457            remote_download_bytes: Arc::new(AtomicU64::new(0)),
458            pending_remote_actions: Arc::new(Mutex::new(BTreeMap::new())),
459            remote_transfers,
460            prefetch_transfers: Arc::new(tokio::sync::Semaphore::new(MAX_PREFETCH_TRANSFERS)),
461            prefetch_tasks: Arc::new(Mutex::new(Vec::new())),
462            warnings: Arc::new(Mutex::new(BTreeSet::new())),
463            file_digests: Arc::new(Mutex::new(BTreeMap::new())),
464            uploads,
465        }
466    }
467
468    /// Report each accounted cache decision to `observer` as it happens.
469    #[must_use]
470    pub fn with_observer(mut self, observer: Arc<dyn AgentEventObserver>) -> Self {
471        self.observer = Some(observer);
472        self
473    }
474
475    fn emit(&self, event: impl FnOnce() -> AgentEvent) {
476        if let Some(observer) = &self.observer {
477            observer.event(event());
478        }
479    }
480
481    fn reserve_remote_download(&self, bytes: u64) -> Result<RemoteDownloadReservation> {
482        let mut current = self.remote_download_bytes.load(Ordering::Acquire);
483        loop {
484            let next = current
485                .checked_add(bytes)
486                .ok_or_else(|| eyre::eyre!("remote cache download budget overflowed"))?;
487            if next > self.remote_download_limit {
488                bail!(
489                    "remote cache download budget exceeded: {} bytes requested with {} of {} bytes already used",
490                    bytes,
491                    current,
492                    self.remote_download_limit
493                );
494            }
495            match self.remote_download_bytes.compare_exchange_weak(
496                current,
497                next,
498                Ordering::AcqRel,
499                Ordering::Acquire,
500            ) {
501                Ok(_) => {
502                    return Ok(RemoteDownloadReservation {
503                        counter: self.remote_download_bytes.clone(),
504                        reserved: bytes,
505                        committed: false,
506                    });
507                }
508                Err(observed) => current = observed,
509            }
510        }
511    }
512
513    fn reserve_remote_download_up_to(&self, requested: u64) -> Result<RemoteDownloadReservation> {
514        let mut current = self.remote_download_bytes.load(Ordering::Acquire);
515        loop {
516            let reserved = requested.min(self.remote_download_limit.saturating_sub(current));
517            let next = current + reserved;
518            match self.remote_download_bytes.compare_exchange_weak(
519                current,
520                next,
521                Ordering::AcqRel,
522                Ordering::Acquire,
523            ) {
524                Ok(_) => {
525                    return Ok(RemoteDownloadReservation {
526                        counter: self.remote_download_bytes.clone(),
527                        reserved,
528                        committed: false,
529                    });
530                }
531                Err(observed) => current = observed,
532            }
533        }
534    }
535
536    /// Load the last committed action manifest for a task into this session.
537    pub async fn begin_task(&self, task: &str) -> Result<String> {
538        self.begin_task_with_remote_errors(task, false).await
539    }
540
541    /// Load a task and finish its prefetch, surfacing remote lookup failures.
542    pub async fn prefetch_task(&self, task: &str) -> Result<String> {
543        let run = self.begin_task_with_remote_errors(task, true).await?;
544        self.wait_for_prefetches().await;
545        Ok(run)
546    }
547
548    async fn begin_task_with_remote_errors(&self, task: &str, strict: bool) -> Result<String> {
549        validate_task_identity(task)?;
550        // The local manifest is already enough to start useful work. Do not
551        // leave those predictions idle while a high-latency remote answers the
552        // manifest lookup; the authoritative snapshot is loaded again below
553        // before it is merged, so another process can still update it while
554        // this request is in flight.
555        let early_predictions = {
556            let _write_guard = self.manifest_write_lock.lock().unwrap();
557            let _file_guard = self.lock_task_manifest(task)?;
558            self.load_task_manifest(task)?
559                .map(|manifest| manifest.predictions)
560                .unwrap_or_default()
561        };
562        let early_actions: BTreeSet<_> = early_predictions
563            .iter()
564            .map(|prediction| prediction.action.clone())
565            .collect();
566        self.spawn_prefetch_predictions(early_predictions);
567        let (remote_manifest, mut remote_etag) = if self.remote_mode.reads() {
568            match self.get_remote_task_manifest(task).await {
569                Ok(Some((manifest, etag))) => (Some(manifest), Some(etag)),
570                Ok(None) => (None, None),
571                Err(error) => {
572                    if strict {
573                        return Err(error).wrap_err_with(|| {
574                            format!("remote task action manifest lookup failed for {task}")
575                        });
576                    }
577                    self.note_remote_failure();
578                    warn!("remote task action manifest lookup failed for {task}: {error}");
579                    (None, None)
580                }
581            }
582        } else {
583            (None, None)
584        };
585        let manifest = {
586            let _write_guard = self.manifest_write_lock.lock().unwrap();
587            let _file_guard = self.lock_task_manifest(task)?;
588            let local_manifest = self.load_task_manifest(task)?;
589            let manifest = match (remote_manifest, local_manifest) {
590                (Some(remote), Some(local)) => {
591                    let (manifest, merged) = merge_remote_task_manifest(task, remote, local);
592                    if !merged {
593                        remote_etag = None;
594                    }
595                    Some(manifest)
596                }
597                (Some(remote), None) => Some(remote),
598                (None, local) => local,
599            };
600            if let Some(manifest) = &manifest {
601                self.persist_task_manifest(manifest)?;
602            }
603            manifest
604        };
605        let state = if let Some(manifest) = manifest {
606            TaskActionState {
607                manifest: task.to_string(),
608                baseline_loaded: true,
609                predictions: manifest
610                    .predictions
611                    .into_iter()
612                    .map(|prediction| (prediction.invocation.clone(), prediction))
613                    .collect(),
614                pending_predictions: BTreeMap::new(),
615                remote_etag,
616            }
617        } else {
618            TaskActionState {
619                manifest: task.to_string(),
620                baseline_loaded: true,
621                remote_etag,
622                ..TaskActionState::default()
623            }
624        };
625        let sequence = self.next_task_run.fetch_add(1, Ordering::Relaxed);
626        let run =
627            CacheDigest::blake3(format!("{task}\0{}\0{sequence}", std::process::id()).as_bytes())
628                .hash;
629        // The largest baseline rather than a sum: beginning the same task again
630        // in one session reloads the same manifest, and counting it twice would
631        // overstate what there was to match.
632        self.stats.predictions_loaded.fetch_max(
633            state.predictions.len().try_into().unwrap_or(u64::MAX),
634            Ordering::Relaxed,
635        );
636        // The early local wave already owns these actions. Only launch a
637        // second wave for predictions learned from the remote or from a local
638        // writer that committed while its lookup was in flight.
639        let predictions = state
640            .predictions
641            .values()
642            .filter(|prediction| !early_actions.contains(&prediction.action))
643            .cloned()
644            .collect();
645        self.task_actions.lock().unwrap().insert(run.clone(), state);
646        self.spawn_prefetch_predictions(predictions);
647        Ok(run)
648    }
649
650    /// Cancel speculative downloads before the owning session exits.
651    pub async fn cancel_prefetches(&self) {
652        let tasks = std::mem::take(&mut *self.prefetch_tasks.lock().unwrap());
653        for task in &tasks {
654            task.abort();
655        }
656        for task in tasks {
657            if let Err(error) = task.await
658                && !error.is_cancelled()
659            {
660                warn!("remote action prefetch task failed: {error}");
661            }
662        }
663    }
664
665    /// Publish everything a build queued, before the session stops.
666    ///
667    /// Store requests return once an object is durable locally, so at this point
668    /// the remote cache may still be behind the local one. Uploads run on the
669    /// session's runtime and are abandoned if it goes away, so a session that
670    /// wants them published has to wait here.
671    pub async fn wait_for_uploads(&self) {
672        let Some(uploads) = &self.uploads else {
673            return;
674        };
675        let _timer = AtomicDurationTimer::start(&self.stats.upload_drain_duration_ns);
676        uploads.drain().await;
677    }
678
679    async fn wait_for_prefetches(&self) {
680        let tasks = std::mem::take(&mut *self.prefetch_tasks.lock().unwrap());
681        for task in tasks {
682            if let Err(error) = task.await {
683                warn!("remote action prefetch task failed: {error}");
684            }
685        }
686    }
687
688    /// Atomically publish the completed actions collected by a task run.
689    pub async fn commit_task(&self, run: &str) -> Result<()> {
690        self.commit_task_actions(run).await.map(|_| ())
691    }
692
693    /// Publish a task run and return exactly the predictions completed by it.
694    ///
695    /// The persisted task manifest also carries predictions inherited from
696    /// earlier runs. Callers that need a receipt for this one run must not
697    /// mistake that cumulative manifest for the work the run completed.
698    pub async fn commit_task_actions(&self, run: &str) -> Result<Vec<ActionPrediction>> {
699        validate_task_identity(run)?;
700        let state = self
701            .task_actions
702            .lock()
703            .unwrap()
704            .get(run)
705            .cloned()
706            .ok_or_else(|| eyre::eyre!("task action manifest baseline was not loaded"))?;
707        if !state.baseline_loaded {
708            bail!("task action manifest baseline was not loaded");
709        }
710        let task = state.manifest;
711        validate_task_identity(&task)?;
712        let completed = state
713            .pending_predictions
714            .values()
715            .cloned()
716            .collect::<Vec<_>>();
717        let (manifest, introduced) = {
718            let _write_guard = self.manifest_write_lock.lock().unwrap();
719            let _file_guard = self.lock_task_manifest(&task)?;
720            let mut predictions = self
721                .load_task_manifest(&task)?
722                .map(|manifest| {
723                    manifest
724                        .predictions
725                        .into_iter()
726                        .map(|prediction| (prediction.invocation.clone(), prediction))
727                        .collect::<BTreeMap<_, _>>()
728                })
729                .unwrap_or_default();
730            // Which predictions this run adds, as opposed to inherits. Only a
731            // new one may be withheld for a failed upload: retracting an
732            // inherited one would un-advertise a result that is plausibly still
733            // on the server from whichever session put it there.
734            let introduced: BTreeSet<CacheDigest> = state
735                .pending_predictions
736                .keys()
737                .filter(|invocation| !predictions.contains_key(*invocation))
738                .cloned()
739                .collect();
740            // Only publish predictions recorded by this run. `predictions`
741            // also contains the baseline loaded by `begin_task`; extending
742            // with that snapshot would overwrite newer entries committed by
743            // another agent process after this run began.
744            predictions.extend(state.pending_predictions);
745            let manifest = TaskActionManifest {
746                version: TASK_ACTION_MANIFEST_VERSION,
747                task: task.clone(),
748                predictions: predictions.into_values().collect(),
749            };
750            validate_task_manifest(&manifest, &task)?;
751            self.persist_task_manifest(&manifest)?;
752            (manifest, introduced)
753        };
754        self.task_actions.lock().unwrap().remove(run);
755        if self.remote_mode.writes() {
756            // A manifest advertises the actions it predicts, so it must not
757            // reach the remote cache before the results a reader would then go
758            // looking for -- nor name a result that never got there at all.
759            let mut manifest = manifest;
760            if let Some(uploads) = &self.uploads {
761                let actions: Vec<CacheDigest> = manifest
762                    .predictions
763                    .iter()
764                    .map(|prediction| prediction.action.clone())
765                    .collect();
766                let unpublished = uploads.wait_for_actions(&actions).await;
767                let withheld = manifest
768                    .predictions
769                    .iter()
770                    .filter(|prediction| {
771                        introduced.contains(&prediction.invocation)
772                            && unpublished.contains(&prediction.action)
773                    })
774                    .count();
775                if withheld > 0 {
776                    // The local manifest keeps them: this checkout can still use
777                    // what it built, and a later session can publish it.
778                    warn!(
779                        "{withheld} of {} predicted actions were not published, so the remote task action manifest omits them",
780                        manifest.predictions.len()
781                    );
782                    manifest.predictions.retain(|prediction| {
783                        !(introduced.contains(&prediction.invocation)
784                            && unpublished.contains(&prediction.action))
785                    });
786                }
787            }
788            match self
789                .put_remote_task_manifest(&task, manifest, state.remote_etag)
790                .await
791            {
792                Ok(remote_manifest) => {
793                    let _write_guard = self.manifest_write_lock.lock().unwrap();
794                    let reconciliation = (|| {
795                        let _file_guard = self.lock_task_manifest(&task)?;
796                        let manifest = match self.load_task_manifest(&task)? {
797                            Some(local) => {
798                                merge_remote_task_manifest(&task, remote_manifest, local).0
799                            }
800                            None => remote_manifest,
801                        };
802                        self.persist_task_manifest(&manifest)
803                    })();
804                    if let Err(error) = reconciliation {
805                        warn!(
806                            "remote task action manifest reconciliation failed for {task}: {error}"
807                        );
808                    }
809                }
810                Err(error) => {
811                    self.note_remote_failure();
812                    warn!("remote task action manifest upload failed for {task}: {error}");
813                }
814            }
815        }
816        Ok(completed)
817    }
818
819    fn task_manifest_path(&self, task: &str) -> PathBuf {
820        self.manifest_dir.join(format!("{task}.json"))
821    }
822
823    fn task_manifest_lock_path(&self, task: &str) -> PathBuf {
824        self.manifest_dir.join("locks").join(format!("{task}.lock"))
825    }
826
827    fn lock_task_manifest(&self, task: &str) -> Result<fslock::LockFile> {
828        let path = self.task_manifest_lock_path(task);
829        fs::create_dir_all(path.parent().expect("task manifest lock has a parent"))?;
830        let mut lock = fslock::LockFile::open(&path)?;
831        lock.lock()?;
832        Ok(lock)
833    }
834
835    fn load_task_manifest(&self, task: &str) -> Result<Option<TaskActionManifest>> {
836        match fs::read(self.task_manifest_path(task)) {
837            Ok(contents) => Ok(Some(self.parse_task_manifest(task, &contents, false)?)),
838            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
839            Err(error) => Err(error.into()),
840        }
841    }
842
843    fn parse_task_manifest(
844        &self,
845        task: &str,
846        contents: &[u8],
847        require_canonical: bool,
848    ) -> Result<TaskActionManifest> {
849        let manifest: TaskActionManifest = serde_json::from_slice(contents)?;
850        validate_task_manifest(&manifest, task)?;
851        if require_canonical && canonical_json(&manifest)? != contents {
852            bail!("task action manifest is not canonical JSON");
853        }
854        Ok(manifest)
855    }
856
857    fn task_manifest_selector(task: &str) -> Result<(Vec<u8>, CacheDigest)> {
858        TaskActionManifest::selector(task)
859    }
860
861    fn persist_task_manifest(&self, manifest: &TaskActionManifest) -> Result<()> {
862        let bytes = canonical_json(manifest)?;
863        fs::create_dir_all(self.manifest_dir.as_path())?;
864        let mut temporary = tempfile::NamedTempFile::new_in(self.manifest_dir.as_path())?;
865        std::io::Write::write_all(temporary.as_file_mut(), &bytes)?;
866        temporary.as_file_mut().sync_all()?;
867        temporary
868            .persist(self.task_manifest_path(&manifest.task))
869            .map_err(|error| error.error)?;
870        Ok(())
871    }
872
873    async fn get_remote_task_manifest(
874        &self,
875        task: &str,
876    ) -> Result<Option<(TaskActionManifest, String)>> {
877        let Some(remote) = &self.remote else {
878            return Ok(None);
879        };
880        let (_, selector) = Self::task_manifest_selector(task)?;
881        let _permit = self.remote_transfers.acquire().await?;
882        self.stats
883            .remote_manifest_lookups
884            .fetch_add(1, Ordering::Relaxed);
885        let _timer = AtomicDurationTimer::start(&self.stats.remote_manifest_lookup_duration_ns);
886        let Some(remote_manifest) = remote.get_action_manifest(&selector).await? else {
887            return Ok(None);
888        };
889        let manifest = self.parse_task_manifest(task, &remote_manifest.bytes, true)?;
890        Ok(Some((manifest, remote_manifest.etag)))
891    }
892
893    async fn put_remote_task_manifest(
894        &self,
895        task: &str,
896        mut manifest: TaskActionManifest,
897        mut expected_etag: Option<String>,
898    ) -> Result<TaskActionManifest> {
899        let Some(remote) = &self.remote else {
900            return Ok(manifest);
901        };
902        let (_, selector) = Self::task_manifest_selector(task)?;
903        for _ in 0..4 {
904            let bytes = canonical_json(&manifest)?;
905            let outcome = {
906                let _permit = self.remote_transfers.acquire().await?;
907                remote
908                    .put_action_manifest(&selector, &bytes, expected_etag.as_deref())
909                    .await?
910            };
911            match outcome {
912                ManifestPutOutcome::Stored => return Ok(manifest),
913                ManifestPutOutcome::PreconditionFailed => {
914                    let Some((remote_manifest, etag)) = self.get_remote_task_manifest(task).await?
915                    else {
916                        expected_etag = None;
917                        continue;
918                    };
919                    manifest = merge_task_manifests(task, Some(remote_manifest), manifest)?;
920                    expected_etag = Some(etag);
921                }
922            }
923        }
924        bail!("remote task action manifest changed too frequently")
925    }
926
927    /// Return a snapshot of this session's cache activity.
928    pub fn stats(&self) -> AgentStats {
929        AgentStats {
930            session_duration_ns: 0,
931            lookups: self.stats.lookups.load(Ordering::Relaxed),
932            unconsulted: self.stats.unconsulted.load(Ordering::Relaxed),
933            hits: self.stats.hits.load(Ordering::Relaxed),
934            stores: self.stats.stores.load(Ordering::Relaxed),
935            stored_bytes: self.stats.stored_bytes.load(Ordering::Relaxed),
936            verifications: self.stats.verifications.load(Ordering::Relaxed),
937            divergences: self.stats.divergences.load(Ordering::Relaxed),
938            downloaded_bytes: self.stats.downloaded_bytes.load(Ordering::Relaxed),
939            uploaded_bytes: self.stats.uploaded_bytes.load(Ordering::Relaxed),
940            background_uploads: self.stats.background_uploads.load(Ordering::Relaxed),
941            background_upload_failures: self
942                .stats
943                .background_upload_failures
944                .load(Ordering::Relaxed),
945            remote_blob_pack_uploads: self.stats.remote_blob_pack_uploads.load(Ordering::Relaxed),
946            remote_blob_pack_upload_blobs: self
947                .stats
948                .remote_blob_pack_upload_blobs
949                .load(Ordering::Relaxed),
950            upload_drain_duration_ns: self.stats.upload_drain_duration_ns.load(Ordering::Relaxed),
951            prefetched_actions: self.stats.prefetched_actions.load(Ordering::Relaxed),
952            predictions_loaded: self.stats.predictions_loaded.load(Ordering::Relaxed),
953            bypasses: self.stats.bypasses.lock().unwrap().clone(),
954            avoided_compiler_duration_ns: self
955                .stats
956                .avoided_compiler_duration_ns
957                .load(Ordering::Relaxed),
958            compiler: self.stats.compiler.lock().unwrap().clone(),
959            slow_compilations: self.stats.slow_compilations.lock().unwrap().clone(),
960            remote_failures: self.stats.remote_failures.load(Ordering::Relaxed),
961            remote_manifest_lookups: self.stats.remote_manifest_lookups.load(Ordering::Relaxed),
962            remote_manifest_lookup_duration_ns: self
963                .stats
964                .remote_manifest_lookup_duration_ns
965                .load(Ordering::Relaxed),
966            remote_action_lookups: self.stats.remote_action_lookups.load(Ordering::Relaxed),
967            remote_action_lookup_duration_ns: self
968                .stats
969                .remote_action_lookup_duration_ns
970                .load(Ordering::Relaxed),
971            remote_blob_requests: self.stats.remote_blob_requests.load(Ordering::Relaxed),
972            remote_blob_pack_requests: self.stats.remote_blob_pack_requests.load(Ordering::Relaxed),
973            remote_blob_pack_blobs: self.stats.remote_blob_pack_blobs.load(Ordering::Relaxed),
974            remote_blob_transfer_duration_ns: self
975                .stats
976                .remote_blob_transfer_duration_ns
977                .load(Ordering::Relaxed),
978            local_cas_write_duration_ns: self
979                .stats
980                .local_cas_write_duration_ns
981                .load(Ordering::Relaxed),
982            prefetch_runs: self.stats.prefetch_runs.load(Ordering::Relaxed),
983            prefetch_duration_ns: self.stats.prefetch_duration_ns.load(Ordering::Relaxed),
984            materialization_duration_ns: self
985                .stats
986                .materialization_duration_ns
987                .load(Ordering::Relaxed),
988            restored_output_files: self.stats.restored_output_files.load(Ordering::Relaxed),
989            restored_output_bytes: self.stats.restored_output_bytes.load(Ordering::Relaxed),
990            reflinked_output_files: self.stats.reflinked_output_files.load(Ordering::Relaxed),
991            reflinked_output_bytes: self.stats.reflinked_output_bytes.load(Ordering::Relaxed),
992            copied_output_files: self.stats.copied_output_files.load(Ordering::Relaxed),
993            copied_output_bytes: self.stats.copied_output_bytes.load(Ordering::Relaxed),
994            reused_output_files: self.stats.reused_output_files.load(Ordering::Relaxed),
995            reused_output_bytes: self.stats.reused_output_bytes.load(Ordering::Relaxed),
996        }
997    }
998
999    /// Handle requests without a transport connection.
1000    ///
1001    /// Persistent wrappers use this entry point when Cargo invokes mbx outside
1002    /// an orchestrated session. It intentionally has the same response
1003    /// semantics as [`Self::handle_connection`], while leaving framing and the
1004    /// version handshake to callers that actually cross a process boundary.
1005    pub async fn handle_requests(
1006        &self,
1007        requests: impl IntoIterator<Item = AgentRequest>,
1008    ) -> Vec<AgentResponse> {
1009        let mut connection = ConnectionUploads::default();
1010        let mut responses = Vec::new();
1011        for request in requests {
1012            responses.push(self.respond_on(request, &mut connection).await);
1013        }
1014        responses
1015    }
1016
1017    fn write_lock(&self, digest: &CacheDigest) -> Arc<tokio::sync::Mutex<()>> {
1018        Self::digest_lock(&self.write_locks, digest)
1019    }
1020
1021    fn action_lock(&self, digest: &CacheDigest) -> Arc<tokio::sync::Mutex<()>> {
1022        Self::digest_lock(&self.action_locks, digest)
1023    }
1024
1025    fn digest_lock(
1026        locks: &Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>,
1027        digest: &CacheDigest,
1028    ) -> Arc<tokio::sync::Mutex<()>> {
1029        let mut locks = locks.lock().unwrap();
1030        locks.retain(|_, lock| lock.strong_count() > 0);
1031        if let Some(lock) = locks.get(digest).and_then(Weak::upgrade) {
1032            return lock;
1033        }
1034        let lock = Arc::new(tokio::sync::Mutex::new(()));
1035        locks.insert(digest.clone(), Arc::downgrade(&lock));
1036        lock
1037    }
1038
1039    /// Answer one request outside any connection.
1040    ///
1041    /// An upload queued this way has no sibling requests to order against, so
1042    /// the queue falls back to treating every blob it holds as a prerequisite.
1043    #[cfg(test)]
1044    async fn respond(&self, request: AgentRequest) -> AgentResponse {
1045        self.respond_on(request, &mut ConnectionUploads::default())
1046            .await
1047    }
1048
1049    async fn respond_on(
1050        &self,
1051        request: AgentRequest,
1052        connection: &mut ConnectionUploads,
1053    ) -> AgentResponse {
1054        let result = match request {
1055            AgentRequest::BeginTask { task } => self
1056                .begin_task(&task)
1057                .await
1058                .map(|run| AgentResponse::TaskBegun { run }),
1059            AgentRequest::CommitTask { run } => self
1060                .commit_task(&run)
1061                .await
1062                .map(|()| AgentResponse::TaskCommitted),
1063            AgentRequest::FindBlob { digest } => self.find_blob(&digest).await,
1064            AgentRequest::FindBlobs { digests } => self.find_blobs(digests).await,
1065            AgentRequest::StoreBlob { digest, source } => {
1066                self.store_blob(&digest, &source, connection).await
1067            }
1068            AgentRequest::FindActionResult { action } => {
1069                self.stats.lookups.fetch_add(1, Ordering::Relaxed);
1070                self.find_action_result(&action).await
1071            }
1072            AgentRequest::RecordActionHit {
1073                action,
1074                restore,
1075                crate_name,
1076            } => self.record_action_hit(&action, restore, crate_name),
1077            AgentRequest::RecordBypass { kind } => {
1078                *self
1079                    .stats
1080                    .bypasses
1081                    .lock()
1082                    .unwrap()
1083                    .entry(kind.clone())
1084                    .or_insert(0) += 1;
1085                self.emit(|| AgentEvent::Bypass { kind });
1086                Ok(AgentResponse::BypassRecorded)
1087            }
1088            AgentRequest::RecordUnconsulted => {
1089                self.stats.unconsulted.fetch_add(1, Ordering::Relaxed);
1090                self.emit(|| AgentEvent::Unconsulted);
1091                Ok(AgentResponse::UnconsultedRecorded)
1092            }
1093            AgentRequest::RecordWarning { message } => self.record_warning(message),
1094            AgentRequest::FindFileDigests { scope, files } => self.find_file_digests(scope, files),
1095            AgentRequest::JoinActionPromise {
1096                adapter,
1097                invocation,
1098            } => self.join_action_promise(&adapter, &invocation).await,
1099            AgentRequest::CompleteActionPromise { claim, prediction } => {
1100                self.complete_action_promise(&claim, &prediction).await
1101            }
1102            AgentRequest::RecordFileDigests { scope, entries } => {
1103                self.record_file_digests(scope, entries)
1104            }
1105            AgentRequest::RecordCompilerInvocation {
1106                outcome,
1107                crate_name,
1108                duration_ns,
1109            } => self.record_compiler_invocation(&outcome, crate_name.as_deref(), duration_ns),
1110            AgentRequest::RecordActionVerification { matched, restore } => {
1111                self.record_materialization(restore);
1112                self.stats.verifications.fetch_add(1, Ordering::Relaxed);
1113                if !matched {
1114                    self.stats.divergences.fetch_add(1, Ordering::Relaxed);
1115                }
1116                self.emit(|| AgentEvent::Verification { matched, restore });
1117                Ok(AgentResponse::ActionVerificationRecorded)
1118            }
1119            AgentRequest::StoreActionResult { result } => {
1120                self.store_action_result(&result, connection).await
1121            }
1122            AgentRequest::FindActionPrediction { task, invocation } => {
1123                self.find_action_prediction(&task, &invocation)
1124            }
1125            AgentRequest::RecordActionPrediction { task, prediction } => {
1126                self.record_action_prediction(&task, prediction)
1127            }
1128            AgentRequest::FindExecutableIdentity {
1129                executable,
1130                environment,
1131            } => self.find_executable_identity(executable, environment),
1132            AgentRequest::StoreExecutableIdentity {
1133                executable,
1134                environment,
1135                stdout,
1136            } => self.store_executable_identity(executable, environment, stdout),
1137            AgentRequest::Hello { .. } => {
1138                Err(eyre::eyre!("hello is only valid as the first request"))
1139            }
1140        };
1141        result.unwrap_or_else(|error| AgentResponse::Error {
1142            message: error.to_string(),
1143        })
1144    }
1145
1146    async fn find_blob(&self, digest: &CacheDigest) -> Result<AgentResponse> {
1147        if let Some(path) = self.find_verified_blob(digest)? {
1148            return Ok(AgentResponse::Blob { path: Some(path) });
1149        }
1150        if !self.remote_mode.reads() {
1151            return Ok(AgentResponse::Blob { path: None });
1152        }
1153        let Some(remote) = &self.remote else {
1154            return Ok(AgentResponse::Blob { path: None });
1155        };
1156        match self.fetch_remote_blob(remote, digest).await {
1157            Ok(path) => Ok(AgentResponse::Blob { path: Some(path) }),
1158            Err(error) => {
1159                warn!(
1160                    "remote cache blob lookup failed for {}: {error}",
1161                    digest.hash
1162                );
1163                Ok(AgentResponse::Blob { path: None })
1164            }
1165        }
1166    }
1167
1168    async fn find_blobs(&self, digests: Vec<CacheDigest>) -> Result<AgentResponse> {
1169        let mut paths = BTreeMap::new();
1170        let mut missing = Vec::new();
1171        for digest in &digests {
1172            match self.find_verified_blob(digest)? {
1173                Some(path) => {
1174                    paths.insert(digest.clone(), path);
1175                }
1176                None => {
1177                    missing.push(digest.clone());
1178                }
1179            }
1180        }
1181
1182        if !missing.is_empty()
1183            && self.remote_mode.reads()
1184            && let Some(remote) = &self.remote
1185        {
1186            paths.extend(self.fetch_remote_blobs(remote, missing, None).await);
1187        }
1188
1189        Ok(AgentResponse::Blobs {
1190            paths: digests
1191                .into_iter()
1192                .map(|digest| paths.get(&digest).cloned())
1193                .collect(),
1194        })
1195    }
1196
1197    async fn store_blob(
1198        &self,
1199        digest: &CacheDigest,
1200        source: &Path,
1201        connection: &mut ConnectionUploads,
1202    ) -> Result<AgentResponse> {
1203        let path = {
1204            let lock = self.write_lock(digest);
1205            let _guard = lock.lock().await;
1206            if let Some(path) = self.find_verified_blob(digest)? {
1207                path
1208            } else {
1209                let path = self.cas.store_file(digest, source)?;
1210                self.remember_verified_blob(digest, &path);
1211                self.stats.stores.fetch_add(1, Ordering::Relaxed);
1212                self.stats
1213                    .stored_bytes
1214                    .fetch_add(digest.size, Ordering::Relaxed);
1215                path
1216            }
1217        };
1218        // The object is durable locally, so the build has what it needs and the
1219        // remote publication can happen after this request returns.
1220        if let Some(uploads) = &self.uploads {
1221            uploads.queue_blob(digest, path.clone(), connection);
1222        }
1223        Ok(AgentResponse::Stored { path })
1224    }
1225
1226    fn find_verified_blob(&self, digest: &CacheDigest) -> Result<Option<PathBuf>> {
1227        let remembered = self.verified_blobs.lock().unwrap().get(digest).cloned();
1228        if let Some(remembered) = remembered {
1229            // The contents behind this digest were hashed in full when the
1230            // session first reached for them. Hashing again on every later
1231            // lookup would re-read each dependency's rlib once per crate that
1232            // links it, so what is rechecked here is that the file has not
1233            // been written since: an overwrite moves the modification time,
1234            // and truncation or eviction changes the length or removes it.
1235            // Only a replacement that reproduces both -- which no writer in
1236            // the store does, since blobs are published by rename under a
1237            // content-derived name -- would go unnoticed until `mbx cache
1238            // verify` reads the store back in full.
1239            if remembered.is_unchanged() {
1240                return Ok(Some(remembered.path));
1241            }
1242            self.verified_blobs.lock().unwrap().remove(digest);
1243        }
1244        let path = self.cas.find(digest)?;
1245        if let Some(path) = &path {
1246            self.remember_verified_blob(digest, path);
1247        }
1248        Ok(path)
1249    }
1250
1251    fn remember_verified_blob(&self, digest: &CacheDigest, path: &Path) {
1252        let Some(verified) = VerifiedBlob::describe(path) else {
1253            // Without an identity to compare against there is nothing to
1254            // shortcut safely, so the digest stays unremembered and every
1255            // lookup re-reads it.
1256            return;
1257        };
1258        self.verified_blobs
1259            .lock()
1260            .unwrap()
1261            .insert(digest.clone(), verified);
1262    }
1263
1264    async fn find_action_result(&self, action: &CacheDigest) -> Result<AgentResponse> {
1265        if let Some(result) = self.actions.find(action)? {
1266            return Ok(AgentResponse::ActionResult {
1267                result: Some(result),
1268            });
1269        }
1270        if !self.remote_mode.reads() {
1271            return Ok(AgentResponse::ActionResult { result: None });
1272        }
1273        let Some(remote) = &self.remote else {
1274            return Ok(AgentResponse::ActionResult { result: None });
1275        };
1276        let lock = self.action_lock(action);
1277        let _guard = lock.lock().await;
1278        if let Some(result) = self.actions.find(action)? {
1279            return Ok(AgentResponse::ActionResult {
1280                result: Some(result),
1281            });
1282        }
1283        if let Some(result) = self
1284            .pending_remote_actions
1285            .lock()
1286            .unwrap()
1287            .get(action)
1288            .cloned()
1289        {
1290            return Ok(AgentResponse::ActionResult {
1291                result: Some(result),
1292            });
1293        }
1294        let _permit = self.remote_transfers.acquire().await?;
1295        match self.get_remote_action_result(remote, action).await {
1296            Ok(Some(result)) => {
1297                self.pending_remote_actions
1298                    .lock()
1299                    .unwrap()
1300                    .insert(action.clone(), result.clone());
1301                Ok(AgentResponse::ActionResult {
1302                    result: Some(result),
1303                })
1304            }
1305            Ok(None) => Ok(AgentResponse::ActionResult { result: None }),
1306            Err(error) => {
1307                self.note_remote_failure();
1308                warn!(
1309                    "remote cache action lookup failed for {}: {error}",
1310                    action.hash
1311                );
1312                Ok(AgentResponse::ActionResult { result: None })
1313            }
1314        }
1315    }
1316
1317    async fn store_action_result(
1318        &self,
1319        result: &RemoteActionResult,
1320        connection: &ConnectionUploads,
1321    ) -> Result<AgentResponse> {
1322        let path = self.actions.store(result)?;
1323        if let Some(uploads) = &self.uploads {
1324            uploads.queue_action_result(result, connection);
1325        }
1326        Ok(AgentResponse::ActionStored { path })
1327    }
1328
1329    async fn join_action_promise(
1330        &self,
1331        adapter: &str,
1332        invocation: &CacheDigest,
1333    ) -> Result<AgentResponse> {
1334        if !self.remote_mode.reads() || !self.remote_mode.writes() {
1335            return Ok(AgentResponse::ActionPromise {
1336                claim: None,
1337                prediction: None,
1338            });
1339        }
1340        let Some(remote) = &self.remote else {
1341            return Ok(AgentResponse::ActionPromise {
1342                claim: None,
1343                prediction: None,
1344            });
1345        };
1346        let deadline = Instant::now() + ACTION_PROMISE_WAIT;
1347        loop {
1348            let _permit = self.remote_transfers.acquire().await?;
1349            let state = remote.join_action_promise(invocation, adapter).await;
1350            drop(_permit);
1351            match state {
1352                Ok(Some(ActionPromiseState::Claimed { claim })) => {
1353                    return Ok(AgentResponse::ActionPromise {
1354                        claim: Some(claim),
1355                        prediction: None,
1356                    });
1357                }
1358                Ok(Some(ActionPromiseState::Complete { prediction })) => {
1359                    return Ok(AgentResponse::ActionPromise {
1360                        claim: None,
1361                        prediction: Some(prediction),
1362                    });
1363                }
1364                Ok(Some(ActionPromiseState::Pending { retry_after_ms }))
1365                    if Instant::now() < deadline =>
1366                {
1367                    tokio::time::sleep(Duration::from_millis(retry_after_ms.clamp(10, 5_000)))
1368                        .await;
1369                }
1370                Ok(Some(ActionPromiseState::Pending { .. }) | None) => {
1371                    return Ok(AgentResponse::ActionPromise {
1372                        claim: None,
1373                        prediction: None,
1374                    });
1375                }
1376                Ok(Some(_)) => {
1377                    return Ok(AgentResponse::ActionPromise {
1378                        claim: None,
1379                        prediction: None,
1380                    });
1381                }
1382                Err(error) => {
1383                    self.note_remote_failure();
1384                    warn!(
1385                        "remote cache action promise failed for {}: {error}",
1386                        invocation.hash
1387                    );
1388                    return Ok(AgentResponse::ActionPromise {
1389                        claim: None,
1390                        prediction: None,
1391                    });
1392                }
1393            }
1394        }
1395    }
1396
1397    async fn complete_action_promise(
1398        &self,
1399        claim: &str,
1400        prediction: &ActionPrediction,
1401    ) -> Result<AgentResponse> {
1402        prediction.validate()?;
1403        let Some(remote) = &self.remote else {
1404            return Ok(AgentResponse::ActionPromiseCompleted);
1405        };
1406        let Some(uploads) = &self.uploads else {
1407            return Ok(AgentResponse::ActionPromiseCompleted);
1408        };
1409        if uploads
1410            .wait_for_actions(std::slice::from_ref(&prediction.action))
1411            .await
1412            .contains(&prediction.action)
1413        {
1414            // Never promise an action result the server does not hold. The
1415            // server lease expires and another runner gets to repair it.
1416            return Ok(AgentResponse::ActionPromiseCompleted);
1417        }
1418        let completion = ActionPromiseCompletion {
1419            claim: claim.to_string(),
1420            prediction: prediction.clone(),
1421        };
1422        let _permit = self.remote_transfers.acquire().await?;
1423        if let Err(error) = remote
1424            .complete_action_promise(&prediction.invocation, &completion)
1425            .await
1426        {
1427            self.note_remote_failure();
1428            warn!(
1429                "remote cache action promise completion failed for {}: {error}",
1430                prediction.invocation.hash
1431            );
1432        }
1433        Ok(AgentResponse::ActionPromiseCompleted)
1434    }
1435
1436    /// Record that a remote operation failed and the build carried on without it.
1437    fn note_remote_failure(&self) {
1438        self.stats.remote_failures.fetch_add(1, Ordering::Relaxed);
1439    }
1440
1441    async fn get_remote_action_result(
1442        &self,
1443        remote: &RemoteCacheClient,
1444        action: &CacheDigest,
1445    ) -> Result<Option<RemoteActionResult>> {
1446        self.stats
1447            .remote_action_lookups
1448            .fetch_add(1, Ordering::Relaxed);
1449        let _timer = AtomicDurationTimer::start(&self.stats.remote_action_lookup_duration_ns);
1450        remote.get_action_result(action).await
1451    }
1452
1453    fn record_action_hit(
1454        &self,
1455        action: &CacheDigest,
1456        restore: RestoreStats,
1457        crate_name: Option<String>,
1458    ) -> Result<AgentResponse> {
1459        validate_crate_name(crate_name.as_deref())?;
1460        if self.actions.find(action)?.is_none() {
1461            let pending = self.pending_remote_actions.lock().unwrap().remove(action);
1462            if let Some(result) = pending {
1463                self.actions.store(&result)?;
1464            } else {
1465                bail!("cannot record a hit for a missing action result");
1466            }
1467        }
1468        self.record_restore(restore);
1469        self.stats.hits.fetch_add(1, Ordering::Relaxed);
1470        self.emit(|| AgentEvent::ActionHit {
1471            crate_name,
1472            restore,
1473        });
1474        Ok(AgentResponse::ActionHitRecorded)
1475    }
1476
1477    fn record_restore(&self, restore: RestoreStats) {
1478        self.record_materialization(restore);
1479        atomic_saturating_add(
1480            &self.stats.avoided_compiler_duration_ns,
1481            restore.avoided_compiler_duration_ns,
1482        );
1483        atomic_saturating_add(&self.stats.restored_output_files, restore.output_files);
1484        atomic_saturating_add(&self.stats.restored_output_bytes, restore.output_bytes);
1485        atomic_saturating_add(
1486            &self.stats.reflinked_output_files,
1487            restore.reflinked_output_files,
1488        );
1489        atomic_saturating_add(
1490            &self.stats.reflinked_output_bytes,
1491            restore.reflinked_output_bytes,
1492        );
1493        atomic_saturating_add(&self.stats.copied_output_files, restore.copied_output_files);
1494        atomic_saturating_add(&self.stats.copied_output_bytes, restore.copied_output_bytes);
1495        atomic_saturating_add(&self.stats.reused_output_files, restore.reused_output_files);
1496        atomic_saturating_add(&self.stats.reused_output_bytes, restore.reused_output_bytes);
1497    }
1498
1499    fn record_compiler_invocation(
1500        &self,
1501        outcome: &str,
1502        crate_name: Option<&str>,
1503        duration_ns: u64,
1504    ) -> Result<AgentResponse> {
1505        if !matches!(
1506            outcome,
1507            "miss" | "unconsulted" | "bypass" | "verification" | "incremental"
1508        ) {
1509            bail!("invalid compiler invocation outcome");
1510        }
1511        validate_crate_name(crate_name)?;
1512        let mut compiler = self.stats.compiler.lock().unwrap();
1513        let stats = compiler.entry(outcome.to_string()).or_default();
1514        stats.invocations = stats.invocations.saturating_add(1);
1515        stats.duration_ns = stats.duration_ns.saturating_add(duration_ns);
1516        drop(compiler);
1517        if outcome != "verification"
1518            && let Some(crate_name) = crate_name.filter(|name| !name.is_empty())
1519        {
1520            let mut slow = self.stats.slow_compilations.lock().unwrap();
1521            let duration = slow.entry(crate_name.to_string()).or_default();
1522            *duration = duration.saturating_add(duration_ns);
1523        }
1524        self.emit(|| AgentEvent::CompilerInvocation {
1525            outcome: outcome.to_string(),
1526            crate_name: crate_name.map(str::to_string),
1527            duration_ns,
1528        });
1529        Ok(AgentResponse::CompilerInvocationRecorded)
1530    }
1531
1532    fn record_materialization(&self, restore: RestoreStats) {
1533        atomic_saturating_add(&self.stats.materialization_duration_ns, restore.duration_ns);
1534    }
1535
1536    /// Surface a shim diagnostic on this process's stderr.
1537    ///
1538    /// The agent lives in the process that owns the session, so printing here
1539    /// reaches the terminal running the build rather than the stderr of the
1540    /// compilation the shim stands in for -- which build scripts read as part
1541    /// of the compiler's answer. Deduplicated because one cause tends to fire
1542    /// once per compilation, and capped so a message unique per compilation
1543    /// cannot scroll the build away.
1544    fn record_warning(&self, message: String) -> Result<AgentResponse> {
1545        if message.is_empty()
1546            || message.len() > MAX_WARNING_BYTES
1547            || message.contains(['\n', '\r', '\0'])
1548        {
1549            bail!("invalid shim warning");
1550        }
1551        let mut warnings = self.warnings.lock().unwrap();
1552        if !warnings.contains(&message) && warnings.len() < MAX_WARNINGS {
1553            eprintln!("mbx[warning]: {message}");
1554            self.emit(|| AgentEvent::Warning {
1555                message: message.clone(),
1556            });
1557            warnings.insert(message);
1558        }
1559        Ok(AgentResponse::WarningRecorded)
1560    }
1561
1562    /// Answer file-digest lookups from the session ledger.
1563    ///
1564    /// A recorded digest is returned only when the requested identity matches
1565    /// the recorded one exactly. The caller statted the file to build the
1566    /// identity, so this is the [`VerifiedBlob`] freshness check with the
1567    /// stat moved to the side that was already making it.
1568    fn find_file_digests(
1569        &self,
1570        scope: FileDigestScope,
1571        files: Vec<FileIdentity>,
1572    ) -> Result<AgentResponse> {
1573        if files.len() > MAX_FILE_DIGEST_BATCH {
1574            bail!("too many file-digest lookups in one request");
1575        }
1576        let ledger = self.file_digests.lock().unwrap();
1577        let digests = files
1578            .into_iter()
1579            .map(|file| {
1580                let recorded = ledger.get(&(scope, file.path.clone()))?;
1581                (recorded.file == file).then(|| recorded.digest.clone())
1582            })
1583            .collect();
1584        Ok(AgentResponse::FileDigests { digests })
1585    }
1586
1587    /// Record digests of files a shim read in full, for later reuse.
1588    ///
1589    /// Capped rather than evicted: a session that outgrows the cap loses the
1590    /// shortcut for the overflow and nothing else, and no workload observed so
1591    /// far comes near it.
1592    fn record_file_digests(
1593        &self,
1594        scope: FileDigestScope,
1595        entries: Vec<RecordedFileDigest>,
1596    ) -> Result<AgentResponse> {
1597        if entries.len() > MAX_FILE_DIGEST_BATCH {
1598            bail!("too many file-digest records in one request");
1599        }
1600        for entry in &entries {
1601            if !entry.file.path.is_absolute() {
1602                bail!("file-digest records need absolute paths");
1603            }
1604            entry.digest.validate()?;
1605            if entry.file.len != entry.digest.size {
1606                bail!("file-digest record length does not match its digest");
1607            }
1608        }
1609        let mut ledger = self.file_digests.lock().unwrap();
1610        for entry in entries {
1611            if ledger.len() >= MAX_FILE_DIGEST_ENTRIES
1612                && !ledger.contains_key(&(scope, entry.file.path.clone()))
1613            {
1614                break;
1615            }
1616            ledger.insert((scope, entry.file.path.clone()), entry);
1617        }
1618        Ok(AgentResponse::FileDigestsRecorded)
1619    }
1620
1621    fn find_action_prediction(
1622        &self,
1623        task: &str,
1624        invocation: &CacheDigest,
1625    ) -> Result<AgentResponse> {
1626        validate_task_identity(task)?;
1627        invocation.validate()?;
1628        let prediction = self
1629            .task_actions
1630            .lock()
1631            .unwrap()
1632            .get(task)
1633            .and_then(|state| state.predictions.get(invocation))
1634            .cloned();
1635        Ok(AgentResponse::ActionPrediction { prediction })
1636    }
1637
1638    fn record_action_prediction(
1639        &self,
1640        task: &str,
1641        prediction: ActionPrediction,
1642    ) -> Result<AgentResponse> {
1643        validate_task_identity(task)?;
1644        prediction.validate()?;
1645        let mut tasks = self.task_actions.lock().unwrap();
1646        let state = tasks.entry(task.to_string()).or_default();
1647        if !state.predictions.contains_key(&prediction.invocation)
1648            && state.predictions.len() >= MAX_TASK_ACTION_PREDICTIONS
1649        {
1650            bail!("task action manifest contains too many predictions");
1651        }
1652        state
1653            .predictions
1654            .insert(prediction.invocation.clone(), prediction.clone());
1655        state
1656            .pending_predictions
1657            .insert(prediction.invocation.clone(), prediction);
1658        Ok(AgentResponse::ActionPredictionRecorded)
1659    }
1660
1661    fn executable_identity_key(
1662        &self,
1663        executable: PathBuf,
1664        environment: BTreeMap<String, Option<String>>,
1665    ) -> Result<ExecutableIdentityKey> {
1666        // Restricted to the variables that actually select what an identity
1667        // probe reports: the toolchain rustup resolves, and the SDK a linker
1668        // driver builds against. Anything else would let one key stand for two
1669        // different compilers.
1670        if !environment.keys().all(|name| {
1671            matches!(
1672                name.as_str(),
1673                "RUSTUP_HOME"
1674                    | "RUSTUP_TOOLCHAIN"
1675                    | "SDKROOT"
1676                    | "MACOSX_DEPLOYMENT_TARGET"
1677                    | "LIB"
1678                    | "UCRTVersion"
1679                    | "UniversalCRTSdkDir"
1680                    | "VCToolsInstallDir"
1681                    | "VCToolsVersion"
1682                    | "WindowsSdkDir"
1683                    | "WindowsSDKVersion"
1684            )
1685        }) {
1686            bail!("executable identity contains an unsupported environment variable");
1687        }
1688        Ok(ExecutableIdentityKey {
1689            executable,
1690            environment,
1691        })
1692    }
1693
1694    fn find_executable_identity(
1695        &self,
1696        executable: PathBuf,
1697        environment: BTreeMap<String, Option<String>>,
1698    ) -> Result<AgentResponse> {
1699        let key = self.executable_identity_key(executable, environment)?;
1700        let stdout = self
1701            .executable_identities
1702            .lock()
1703            .unwrap()
1704            .get(&key)
1705            .cloned();
1706        Ok(AgentResponse::ExecutableIdentity { stdout })
1707    }
1708
1709    fn store_executable_identity(
1710        &self,
1711        executable: PathBuf,
1712        environment: BTreeMap<String, Option<String>>,
1713        stdout: Vec<u8>,
1714    ) -> Result<AgentResponse> {
1715        if stdout.len() > MAX_EXECUTABLE_IDENTITY_SIZE {
1716            bail!("executable identity exceeds {MAX_EXECUTABLE_IDENTITY_SIZE} bytes");
1717        }
1718        let key = self.executable_identity_key(executable, environment)?;
1719        let mut identities = self.executable_identities.lock().unwrap();
1720        let is_new = !identities.contains_key(&key);
1721        let previous_size = identities.get(&key).map_or(0, Vec::len);
1722        if is_new && identities.len() >= MAX_EXECUTABLE_IDENTITIES {
1723            bail!("executable identity cache contains too many entries");
1724        }
1725        let retained_bytes = identities.values().map(Vec::len).sum::<usize>();
1726        if retained_bytes - previous_size + stdout.len() > MAX_EXECUTABLE_IDENTITY_BYTES {
1727            bail!("executable identity cache contains too many bytes");
1728        }
1729        identities.insert(key, stdout.clone());
1730        Ok(AgentResponse::ExecutableIdentity {
1731            stdout: Some(stdout),
1732        })
1733    }
1734
1735    /// Serve newline-delimited protocol requests on an authenticated session stream.
1736    pub async fn handle_connection<S>(&self, stream: S) -> Result<()>
1737    where
1738        S: AsyncRead + AsyncWrite + Unpin,
1739    {
1740        let (reader, mut writer) = tokio::io::split(stream);
1741        let mut reader = BufReader::new(reader);
1742        let hello = read_request(&mut reader)
1743            .await?
1744            .ok_or_else(|| eyre::eyre!("connection closed before the agent handshake"))?;
1745        let request: AgentRequest = serde_json::from_str(&hello)?;
1746        match request {
1747            AgentRequest::Hello {
1748                protocol,
1749                client_version,
1750            } if protocol == AGENT_PROTOCOL_VERSION && client_version == self.version.as_ref() => {}
1751            AgentRequest::Hello { protocol, .. } if protocol != AGENT_PROTOCOL_VERSION => {
1752                send_response(
1753                    &mut writer,
1754                    &AgentResponse::Error {
1755                        message: format!(
1756                            "unsupported agent protocol {protocol}; expected {AGENT_PROTOCOL_VERSION}"
1757                        ),
1758                    },
1759                )
1760                .await?;
1761                return Ok(());
1762            }
1763            AgentRequest::Hello { client_version, .. } => {
1764                send_response(
1765                    &mut writer,
1766                    &AgentResponse::Error {
1767                        message: format!(
1768                            "cache client {client_version} does not match agent {}",
1769                            self.version
1770                        ),
1771                    },
1772                )
1773                .await?;
1774                return Ok(());
1775            }
1776            _ => bail!("the first agent request must be hello"),
1777        }
1778        send_response(
1779            &mut writer,
1780            &AgentResponse::Hello {
1781                protocol: AGENT_PROTOCOL_VERSION,
1782                agent_version: self.version.to_string(),
1783            },
1784        )
1785        .await?;
1786
1787        // Tickets accumulate for the life of the connection because a shim
1788        // publishes a compilation's blobs and the action result naming them over
1789        // one connection, in that order.
1790        let mut connection = ConnectionUploads::default();
1791        while let Some(line) = read_request(&mut reader).await? {
1792            let response = match serde_json::from_str(&line) {
1793                Ok(request) => self.respond_on(request, &mut connection).await,
1794                Err(error) => AgentResponse::Error {
1795                    message: format!("invalid agent request: {error}"),
1796                },
1797            };
1798            send_response(&mut writer, &response).await?;
1799        }
1800        Ok(())
1801    }
1802}
1803
1804/// Read one newline-delimited request, refusing one that grows past the cap.
1805///
1806/// Any process running as this user can open the session socket, so a request
1807/// that never terminates its line must not be able to grow the agent's memory
1808/// without bound.
1809async fn read_request<R>(reader: &mut R) -> Result<Option<String>>
1810where
1811    R: AsyncBufRead + Unpin,
1812{
1813    let mut line = Vec::new();
1814    loop {
1815        let available = reader.fill_buf().await?;
1816        if available.is_empty() {
1817            break;
1818        }
1819        let (consumed, complete) = match available.iter().position(|byte| *byte == b'\n') {
1820            Some(index) => (index, true),
1821            None => (available.len(), false),
1822        };
1823        if line.len() + consumed > MAX_REQUEST_BYTES {
1824            bail!("agent request exceeded {MAX_REQUEST_BYTES} bytes");
1825        }
1826        line.extend_from_slice(&available[..consumed]);
1827        // The newline itself is consumed but never kept.
1828        reader.consume(consumed + usize::from(complete));
1829        if complete {
1830            return Ok(Some(String::from_utf8(line)?));
1831        }
1832    }
1833    if line.is_empty() {
1834        Ok(None)
1835    } else {
1836        Ok(Some(String::from_utf8(line)?))
1837    }
1838}
1839
1840async fn send_response(
1841    writer: &mut (impl AsyncWrite + Unpin),
1842    response: &AgentResponse,
1843) -> Result<()> {
1844    let mut encoded = serde_json::to_vec(response)?;
1845    encoded.push(b'\n');
1846    writer.write_all(&encoded).await?;
1847    writer.flush().await?;
1848    Ok(())
1849}
1850
1851#[cfg(test)]
1852#[path = "agent_tests.rs"]
1853mod tests;