Skip to main content

mbx_cache_core/
agent.rs

1use crate::{
2    BlobSource, BlobUpload, CacheDigest, CacheDirectory, LocalActionCache, LocalCas,
3    ManifestPutOutcome, RemoteActionResult, RemoteCacheClient, RemoteCacheMode, RustcMetadata,
4    canonical_json,
5};
6use eyre::{Result, bail};
7use futures_util::{FutureExt, StreamExt, future::BoxFuture, stream};
8use log::warn;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::{Arc, Mutex, Weak};
15use std::time::{Duration, Instant};
16use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
17
18const MAX_EXECUTABLE_IDENTITIES: usize = 64;
19const MAX_EXECUTABLE_IDENTITY_SIZE: usize = 64 * 1024;
20const MAX_EXECUTABLE_IDENTITY_BYTES: usize = 256 * 1024;
21const TASK_ACTION_MANIFEST_VERSION: u8 = 1;
22const MAX_TASK_ACTION_PREDICTIONS: usize = 16 * 1024;
23const MAX_ACTION_PREDICTION_PAYLOAD: usize = 256 * 1024;
24const MAX_REMOTE_TRANSFERS: usize = 64;
25const MAX_PREFETCH_TRANSFERS: usize = 48;
26const MAX_PREFETCH_ACTION_BATCH: usize = 256;
27const PREFETCH_ACTION_BATCH_DELAY: Duration = Duration::from_millis(5);
28const MAX_PREFETCH_DIRECTORY_OBJECTS: usize = 100_000;
29const MAX_PREFETCH_OBJECTS_PER_WAVE: usize = 100_000;
30
31/// Remote action-cache access owned by one task session.
32pub struct AgentRemoteCache {
33    pub client: RemoteCacheClient,
34    pub mode: RemoteCacheMode,
35    pub staging_dir: PathBuf,
36}
37
38/// Wire protocol version used between an in-process cache agent and its shims.
39pub const AGENT_PROTOCOL_VERSION: u8 = 1;
40/// Largest single protocol request the agent will read.
41///
42/// Requests are small JSON objects; the largest legitimate ones carry an output
43/// tree or a batch of digests, which stay far below this.
44const MAX_REQUEST_BYTES: usize = 16 * 1024 * 1024;
45
46/// A request accepted by the task-scoped cache agent.
47#[derive(Debug, Serialize, Deserialize)]
48#[serde(tag = "type", rename_all = "snake_case")]
49pub enum AgentRequest {
50    Hello {
51        protocol: u8,
52        client_version: String,
53    },
54    /// Resolve a blob to a session-verified local CAS path.
55    FindBlob {
56        digest: CacheDigest,
57    },
58    /// Resolve blobs to session-verified local CAS paths.
59    FindBlobs {
60        digests: Vec<CacheDigest>,
61    },
62    StoreBlob {
63        digest: CacheDigest,
64        source: PathBuf,
65    },
66    FindActionResult {
67        action: CacheDigest,
68    },
69    RecordActionHit {
70        action: CacheDigest,
71        restore: RestoreStats,
72    },
73    /// A compilation the adapter declined to cache, grouped by reason.
74    RecordBypass {
75        kind: String,
76    },
77    RecordActionVerification {
78        matched: bool,
79        restore: RestoreStats,
80    },
81    StoreActionResult {
82        result: RemoteActionResult,
83    },
84    FindActionPrediction {
85        task: String,
86        invocation: CacheDigest,
87    },
88    RecordActionPrediction {
89        task: String,
90        prediction: ActionPrediction,
91    },
92    FindExecutableIdentity {
93        executable: PathBuf,
94        environment: BTreeMap<String, Option<String>>,
95    },
96    StoreExecutableIdentity {
97        executable: PathBuf,
98        environment: BTreeMap<String, Option<String>>,
99        stdout: Vec<u8>,
100    },
101}
102
103/// Local output restoration work performed by one action-cache adapter hit.
104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct RestoreStats {
107    /// Cumulative time spent materializing and validating output files.
108    pub duration_ns: u64,
109    /// Number of compiler output files restored.
110    pub output_files: u64,
111    /// Declared size of compiler output files restored.
112    pub output_bytes: u64,
113}
114
115/// A response returned by the task-scoped cache agent.
116#[derive(Debug, Serialize, Deserialize)]
117#[serde(tag = "type", rename_all = "snake_case")]
118pub enum AgentResponse {
119    Hello {
120        protocol: u8,
121        agent_version: String,
122    },
123    /// A local CAS path already verified against the requested digest.
124    Blob {
125        path: Option<PathBuf>,
126    },
127    /// Local CAS paths already verified against the requested digests.
128    Blobs {
129        paths: Vec<Option<PathBuf>>,
130    },
131    Stored {
132        path: PathBuf,
133    },
134    ActionResult {
135        result: Option<RemoteActionResult>,
136    },
137    ActionHitRecorded,
138    ActionVerificationRecorded,
139    BypassRecorded,
140    ActionStored {
141        path: PathBuf,
142    },
143    ActionPrediction {
144        prediction: Option<ActionPrediction>,
145    },
146    ActionPredictionRecorded,
147    ExecutableIdentity {
148        stdout: Option<Vec<u8>>,
149    },
150    Error {
151        message: String,
152    },
153}
154
155/// Aggregate cache activity for one task session.
156#[derive(Debug, Clone, Default, PartialEq, Eq)]
157pub struct AgentStats {
158    /// End-to-end lifetime of the task-scoped cache session.
159    pub session_duration_ns: u64,
160    /// Number of action-result lookups.
161    pub lookups: u64,
162    /// Number of lookups that found a valid local action result.
163    pub hits: u64,
164    /// Number of newly stored content-addressed objects.
165    pub stores: u64,
166    /// Total size of newly stored objects.
167    pub stored_bytes: u64,
168    /// Number of cache hits compiled again for qualification.
169    pub verifications: u64,
170    /// Number of qualification builds that diverged from the cached result.
171    pub divergences: u64,
172    /// CAS payload bytes downloaded from the remote cache.
173    pub downloaded_bytes: u64,
174    /// CAS payload bytes uploaded to the remote cache.
175    pub uploaded_bytes: u64,
176    /// Complete actions staged before an adapter requested them.
177    pub prefetched_actions: u64,
178    /// Compilations that were not cacheable, counted by reason.
179    pub bypasses: BTreeMap<String, u64>,
180    /// Number of task manifest requests made to the remote cache.
181    pub remote_manifest_lookups: u64,
182    /// Cumulative time spent requesting remote task manifests.
183    pub remote_manifest_lookup_duration_ns: u64,
184    /// Number of action-result requests made to the remote cache.
185    pub remote_action_lookups: u64,
186    /// Cumulative time spent requesting remote action results.
187    pub remote_action_lookup_duration_ns: u64,
188    /// Number of blob requests made to the remote cache.
189    pub remote_blob_requests: u64,
190    /// Number of packed blob requests made to the remote cache.
191    pub remote_blob_pack_requests: u64,
192    /// Number of verified blobs received through packed responses.
193    pub remote_blob_pack_blobs: u64,
194    /// Cumulative time spent downloading and verifying remote blobs.
195    pub remote_blob_transfer_duration_ns: u64,
196    /// Cumulative time spent ingesting downloaded blobs into the local CAS.
197    pub local_cas_write_duration_ns: u64,
198    /// Number of speculative prefetch runs started for task manifests.
199    pub prefetch_runs: u64,
200    /// Cumulative wall time of speculative task-manifest prefetch runs.
201    pub prefetch_duration_ns: u64,
202    /// Cumulative time spent staging or materializing and validating cached outputs.
203    pub materialization_duration_ns: u64,
204    /// Number of compiler output files restored from action hits.
205    pub restored_output_files: u64,
206    /// Declared size of compiler output files restored from action hits.
207    pub restored_output_bytes: u64,
208}
209
210/// Adapter-owned data needed to reconstruct an action before fresh dependency
211/// discovery is available.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(deny_unknown_fields)]
214pub struct ActionPrediction {
215    pub invocation: CacheDigest,
216    pub action: CacheDigest,
217    pub adapter: String,
218    pub payload: String,
219}
220
221#[derive(Default)]
222struct AtomicAgentStats {
223    lookups: AtomicU64,
224    hits: AtomicU64,
225    stores: AtomicU64,
226    stored_bytes: AtomicU64,
227    verifications: AtomicU64,
228    divergences: AtomicU64,
229    downloaded_bytes: AtomicU64,
230    uploaded_bytes: AtomicU64,
231    prefetched_actions: AtomicU64,
232    remote_manifest_lookups: AtomicU64,
233    remote_manifest_lookup_duration_ns: AtomicU64,
234    remote_action_lookups: AtomicU64,
235    remote_action_lookup_duration_ns: AtomicU64,
236    remote_blob_requests: AtomicU64,
237    remote_blob_pack_requests: AtomicU64,
238    remote_blob_pack_blobs: AtomicU64,
239    remote_blob_transfer_duration_ns: AtomicU64,
240    local_cas_write_duration_ns: AtomicU64,
241    prefetch_runs: AtomicU64,
242    prefetch_duration_ns: AtomicU64,
243    materialization_duration_ns: AtomicU64,
244    bypasses: Mutex<BTreeMap<String, u64>>,
245    restored_output_files: AtomicU64,
246    restored_output_bytes: AtomicU64,
247}
248
249struct AtomicDurationTimer<'a> {
250    started: Instant,
251    target: &'a AtomicU64,
252}
253
254impl<'a> AtomicDurationTimer<'a> {
255    fn start(target: &'a AtomicU64) -> Self {
256        Self {
257            started: Instant::now(),
258            target,
259        }
260    }
261}
262
263impl Drop for AtomicDurationTimer<'_> {
264    fn drop(&mut self) {
265        atomic_saturating_add(self.target, duration_ns(self.started));
266    }
267}
268
269fn duration_ns(started: Instant) -> u64 {
270    started.elapsed().as_nanos().try_into().unwrap_or(u64::MAX)
271}
272
273fn atomic_saturating_add(target: &AtomicU64, value: u64) {
274    let _ = target.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
275        Some(current.saturating_add(value))
276    });
277}
278
279fn queue_prefetch_digest(
280    verified: &BTreeMap<CacheDigest, PathBuf>,
281    pending: &mut BTreeMap<CacheDigest, ()>,
282    digest: CacheDigest,
283) {
284    if verified.contains_key(&digest) || pending.contains_key(&digest) {
285        return;
286    }
287    pending.insert(digest, ());
288}
289
290fn queue_prefetch_directory(
291    seen: &BTreeMap<CacheDigest, ()>,
292    pending: &mut BTreeMap<CacheDigest, ()>,
293    digest: CacheDigest,
294    limit: usize,
295) -> bool {
296    if seen.contains_key(&digest) || pending.contains_key(&digest) {
297        return true;
298    }
299    if seen.len().saturating_add(pending.len()) >= limit {
300        return false;
301    }
302    pending.insert(digest, ());
303    true
304}
305
306/// Shared state for an agent hosted by the process that owns a build session.
307///
308/// Transport listeners deliberately live in the embedder so the session
309/// lifecycle owns them. This type only contains ecosystem-independent CAS and
310/// protocol logic.
311#[derive(Clone)]
312pub struct CacheAgent {
313    cas: LocalCas,
314    actions: LocalActionCache,
315    verified_blobs: Arc<Mutex<BTreeMap<CacheDigest, PathBuf>>>,
316    version: Arc<str>,
317    write_locks: Arc<Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>>,
318    action_locks: Arc<Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>>,
319    stats: Arc<AtomicAgentStats>,
320    executable_identities: Arc<Mutex<BTreeMap<ExecutableIdentityKey, Vec<u8>>>>,
321    manifest_dir: Arc<PathBuf>,
322    task_actions: Arc<Mutex<BTreeMap<String, TaskActionState>>>,
323    next_task_run: Arc<AtomicU64>,
324    manifest_write_lock: Arc<Mutex<()>>,
325    remote: Option<Arc<RemoteCacheClient>>,
326    remote_mode: RemoteCacheMode,
327    remote_staging_dir: Arc<PathBuf>,
328    pending_remote_actions: Arc<Mutex<BTreeMap<CacheDigest, RemoteActionResult>>>,
329    remote_transfers: Arc<tokio::sync::Semaphore>,
330    prefetch_transfers: Arc<tokio::sync::Semaphore>,
331    prefetch_tasks: Arc<Mutex<Vec<tokio::task::JoinHandle<()>>>>,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
335#[serde(deny_unknown_fields)]
336struct TaskActionManifest {
337    version: u8,
338    task: String,
339    predictions: Vec<ActionPrediction>,
340}
341
342#[derive(Serialize)]
343struct TaskActionManifestSelector<'a> {
344    version: u8,
345    kind: &'static str,
346    task: &'a str,
347}
348
349#[derive(Debug, Clone, Default)]
350struct TaskActionState {
351    manifest: String,
352    baseline_loaded: bool,
353    predictions: BTreeMap<CacheDigest, ActionPrediction>,
354    remote_etag: Option<String>,
355}
356
357struct PrefetchedAction {
358    adapter: String,
359    result: RemoteActionResult,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
363struct ExecutableIdentityKey {
364    executable: PathBuf,
365    environment: BTreeMap<String, Option<String>>,
366}
367
368impl CacheAgent {
369    /// Create an agent backed by the cache rooted at `cache_dir`.
370    pub fn new(cache_dir: impl Into<PathBuf>, version: impl Into<Arc<str>>) -> Self {
371        Self::build(cache_dir.into(), version.into(), None)
372    }
373
374    /// Create an agent with local-first access to a remote action cache.
375    pub fn new_remote(
376        cache_dir: impl Into<PathBuf>,
377        version: impl Into<Arc<str>>,
378        remote: AgentRemoteCache,
379    ) -> Self {
380        Self::build(cache_dir.into(), version.into(), Some(remote))
381    }
382
383    fn build(cache_dir: PathBuf, version: Arc<str>, remote: Option<AgentRemoteCache>) -> Self {
384        let remote_mode = remote
385            .as_ref()
386            .map_or(RemoteCacheMode::ReadOnly, |remote| remote.mode);
387        let remote_staging_dir = remote.as_ref().map_or_else(
388            || cache_dir.join("remote"),
389            |remote| remote.staging_dir.clone(),
390        );
391        let remote = remote.map(|remote| Arc::new(remote.client));
392        Self {
393            cas: LocalCas::new(cache_dir.clone()),
394            actions: LocalActionCache::new(cache_dir.clone()),
395            verified_blobs: Arc::new(Mutex::new(BTreeMap::new())),
396            version,
397            write_locks: Arc::new(Mutex::new(BTreeMap::new())),
398            action_locks: Arc::new(Mutex::new(BTreeMap::new())),
399            stats: Arc::new(AtomicAgentStats::default()),
400            executable_identities: Arc::new(Mutex::new(BTreeMap::new())),
401            manifest_dir: Arc::new(cache_dir.join("task-manifests").join("v1")),
402            task_actions: Arc::new(Mutex::new(BTreeMap::new())),
403            next_task_run: Arc::new(AtomicU64::new(0)),
404            manifest_write_lock: Arc::new(Mutex::new(())),
405            remote,
406            remote_mode,
407            remote_staging_dir: Arc::new(remote_staging_dir),
408            pending_remote_actions: Arc::new(Mutex::new(BTreeMap::new())),
409            remote_transfers: Arc::new(tokio::sync::Semaphore::new(MAX_REMOTE_TRANSFERS)),
410            prefetch_transfers: Arc::new(tokio::sync::Semaphore::new(MAX_PREFETCH_TRANSFERS)),
411            prefetch_tasks: Arc::new(Mutex::new(Vec::new())),
412        }
413    }
414
415    /// Load the last successful action manifest for a task into this session.
416    pub async fn begin_task(&self, task: &str) -> Result<String> {
417        validate_task_identity(task)?;
418        let (remote_manifest, mut remote_etag) = if self.remote_mode.reads() {
419            match self.get_remote_task_manifest(task).await {
420                Ok(Some((manifest, etag))) => (Some(manifest), Some(etag)),
421                Ok(None) => (None, None),
422                Err(error) => {
423                    warn!("remote task action manifest lookup failed for {task}: {error}");
424                    (None, None)
425                }
426            }
427        } else {
428            (None, None)
429        };
430        let manifest = {
431            let _write_guard = self.manifest_write_lock.lock().unwrap();
432            let _file_guard = self.lock_task_manifest(task)?;
433            let local_manifest = self.load_task_manifest(task)?;
434            let manifest = match (remote_manifest, local_manifest) {
435                (Some(remote), Some(local)) => {
436                    let (manifest, merged) = merge_remote_task_manifest(task, remote, local);
437                    if !merged {
438                        remote_etag = None;
439                    }
440                    Some(manifest)
441                }
442                (Some(remote), None) => Some(remote),
443                (None, local) => local,
444            };
445            if let Some(manifest) = &manifest {
446                self.persist_task_manifest(manifest)?;
447            }
448            manifest
449        };
450        let state = if let Some(manifest) = manifest {
451            TaskActionState {
452                manifest: task.to_string(),
453                baseline_loaded: true,
454                predictions: manifest
455                    .predictions
456                    .into_iter()
457                    .map(|prediction| (prediction.invocation.clone(), prediction))
458                    .collect(),
459                remote_etag,
460            }
461        } else {
462            TaskActionState {
463                manifest: task.to_string(),
464                baseline_loaded: true,
465                remote_etag,
466                ..TaskActionState::default()
467            }
468        };
469        let sequence = self.next_task_run.fetch_add(1, Ordering::Relaxed);
470        let run =
471            CacheDigest::blake3(format!("{task}\0{}\0{sequence}", std::process::id()).as_bytes())
472                .hash;
473        let predictions = state.predictions.values().cloned().collect();
474        self.task_actions.lock().unwrap().insert(run.clone(), state);
475        self.spawn_prefetch_predictions(predictions);
476        Ok(run)
477    }
478
479    /// Cancel speculative downloads before the owning session exits.
480    pub async fn cancel_prefetches(&self) {
481        let tasks = std::mem::take(&mut *self.prefetch_tasks.lock().unwrap());
482        for task in &tasks {
483            task.abort();
484        }
485        for task in tasks {
486            if let Err(error) = task.await
487                && !error.is_cancelled()
488            {
489                warn!("remote action prefetch task failed: {error}");
490            }
491        }
492    }
493
494    #[cfg(test)]
495    async fn wait_for_prefetches(&self) {
496        let tasks = std::mem::take(&mut *self.prefetch_tasks.lock().unwrap());
497        for task in tasks {
498            if let Err(error) = task.await {
499                warn!("remote action prefetch task failed: {error}");
500            }
501        }
502    }
503
504    /// Atomically publish the candidate manifest collected by a successful task.
505    pub async fn commit_task(&self, run: &str) -> Result<()> {
506        validate_task_identity(run)?;
507        let state = self
508            .task_actions
509            .lock()
510            .unwrap()
511            .get(run)
512            .cloned()
513            .ok_or_else(|| eyre::eyre!("task action manifest baseline was not loaded"))?;
514        if !state.baseline_loaded {
515            bail!("task action manifest baseline was not loaded");
516        }
517        let task = state.manifest;
518        validate_task_identity(&task)?;
519        let manifest = {
520            let _write_guard = self.manifest_write_lock.lock().unwrap();
521            let _file_guard = self.lock_task_manifest(&task)?;
522            let mut predictions = self
523                .load_task_manifest(&task)?
524                .map(|manifest| {
525                    manifest
526                        .predictions
527                        .into_iter()
528                        .map(|prediction| (prediction.invocation.clone(), prediction))
529                        .collect::<BTreeMap<_, _>>()
530                })
531                .unwrap_or_default();
532            predictions.extend(state.predictions);
533            let manifest = TaskActionManifest {
534                version: TASK_ACTION_MANIFEST_VERSION,
535                task: task.clone(),
536                predictions: predictions.into_values().collect(),
537            };
538            validate_task_manifest(&manifest, &task)?;
539            self.persist_task_manifest(&manifest)?;
540            manifest
541        };
542        self.task_actions.lock().unwrap().remove(run);
543        if self.remote_mode.writes() {
544            match self
545                .put_remote_task_manifest(&task, manifest, state.remote_etag)
546                .await
547            {
548                Ok(remote_manifest) => {
549                    let _write_guard = self.manifest_write_lock.lock().unwrap();
550                    let reconciliation = (|| {
551                        let _file_guard = self.lock_task_manifest(&task)?;
552                        let manifest = match self.load_task_manifest(&task)? {
553                            Some(local) => {
554                                merge_remote_task_manifest(&task, remote_manifest, local).0
555                            }
556                            None => remote_manifest,
557                        };
558                        self.persist_task_manifest(&manifest)
559                    })();
560                    if let Err(error) = reconciliation {
561                        warn!(
562                            "remote task action manifest reconciliation failed for {task}: {error}"
563                        );
564                    }
565                }
566                Err(error) => {
567                    warn!("remote task action manifest upload failed for {task}: {error}");
568                }
569            }
570        }
571        Ok(())
572    }
573
574    fn task_manifest_path(&self, task: &str) -> PathBuf {
575        self.manifest_dir.join(format!("{task}.json"))
576    }
577
578    fn task_manifest_lock_path(&self, task: &str) -> PathBuf {
579        self.manifest_dir.join("locks").join(format!("{task}.lock"))
580    }
581
582    fn lock_task_manifest(&self, task: &str) -> Result<fslock::LockFile> {
583        let path = self.task_manifest_lock_path(task);
584        fs::create_dir_all(path.parent().expect("task manifest lock has a parent"))?;
585        let mut lock = fslock::LockFile::open(&path)?;
586        lock.lock()?;
587        Ok(lock)
588    }
589
590    fn load_task_manifest(&self, task: &str) -> Result<Option<TaskActionManifest>> {
591        match fs::read(self.task_manifest_path(task)) {
592            Ok(contents) => Ok(Some(self.parse_task_manifest(task, &contents, false)?)),
593            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
594            Err(error) => Err(error.into()),
595        }
596    }
597
598    fn parse_task_manifest(
599        &self,
600        task: &str,
601        contents: &[u8],
602        require_canonical: bool,
603    ) -> Result<TaskActionManifest> {
604        let manifest: TaskActionManifest = serde_json::from_slice(contents)?;
605        validate_task_manifest(&manifest, task)?;
606        if require_canonical && canonical_json(&manifest)? != contents {
607            bail!("task action manifest is not canonical JSON");
608        }
609        Ok(manifest)
610    }
611
612    fn task_manifest_selector(task: &str) -> Result<(Vec<u8>, CacheDigest)> {
613        let bytes = canonical_json(&TaskActionManifestSelector {
614            version: 1,
615            kind: "task_action_manifest",
616            task,
617        })?;
618        let digest = CacheDigest::blake3(&bytes);
619        Ok((bytes, digest))
620    }
621
622    fn persist_task_manifest(&self, manifest: &TaskActionManifest) -> Result<()> {
623        let bytes = canonical_json(manifest)?;
624        fs::create_dir_all(self.manifest_dir.as_path())?;
625        let mut temporary = tempfile::NamedTempFile::new_in(self.manifest_dir.as_path())?;
626        std::io::Write::write_all(temporary.as_file_mut(), &bytes)?;
627        temporary.as_file_mut().sync_all()?;
628        temporary
629            .persist(self.task_manifest_path(&manifest.task))
630            .map_err(|error| error.error)?;
631        Ok(())
632    }
633
634    async fn get_remote_task_manifest(
635        &self,
636        task: &str,
637    ) -> Result<Option<(TaskActionManifest, String)>> {
638        let Some(remote) = &self.remote else {
639            return Ok(None);
640        };
641        let (_, selector) = Self::task_manifest_selector(task)?;
642        let _permit = self.remote_transfers.acquire().await?;
643        self.stats
644            .remote_manifest_lookups
645            .fetch_add(1, Ordering::Relaxed);
646        let _timer = AtomicDurationTimer::start(&self.stats.remote_manifest_lookup_duration_ns);
647        let Some(remote_manifest) = remote.get_action_manifest(&selector).await? else {
648            return Ok(None);
649        };
650        let manifest = self.parse_task_manifest(task, &remote_manifest.bytes, true)?;
651        Ok(Some((manifest, remote_manifest.etag)))
652    }
653
654    async fn put_remote_task_manifest(
655        &self,
656        task: &str,
657        mut manifest: TaskActionManifest,
658        mut expected_etag: Option<String>,
659    ) -> Result<TaskActionManifest> {
660        let Some(remote) = &self.remote else {
661            return Ok(manifest);
662        };
663        let (_, selector) = Self::task_manifest_selector(task)?;
664        for _ in 0..4 {
665            let bytes = canonical_json(&manifest)?;
666            let outcome = {
667                let _permit = self.remote_transfers.acquire().await?;
668                remote
669                    .put_action_manifest(&selector, &bytes, expected_etag.as_deref())
670                    .await?
671            };
672            match outcome {
673                ManifestPutOutcome::Stored => return Ok(manifest),
674                ManifestPutOutcome::PreconditionFailed => {
675                    let Some((remote_manifest, etag)) = self.get_remote_task_manifest(task).await?
676                    else {
677                        expected_etag = None;
678                        continue;
679                    };
680                    manifest = merge_task_manifests(task, Some(remote_manifest), manifest)?;
681                    expected_etag = Some(etag);
682                }
683            }
684        }
685        bail!("remote task action manifest changed too frequently")
686    }
687
688    /// Return a snapshot of this session's cache activity.
689    pub fn stats(&self) -> AgentStats {
690        AgentStats {
691            session_duration_ns: 0,
692            lookups: self.stats.lookups.load(Ordering::Relaxed),
693            hits: self.stats.hits.load(Ordering::Relaxed),
694            stores: self.stats.stores.load(Ordering::Relaxed),
695            stored_bytes: self.stats.stored_bytes.load(Ordering::Relaxed),
696            verifications: self.stats.verifications.load(Ordering::Relaxed),
697            divergences: self.stats.divergences.load(Ordering::Relaxed),
698            downloaded_bytes: self.stats.downloaded_bytes.load(Ordering::Relaxed),
699            uploaded_bytes: self.stats.uploaded_bytes.load(Ordering::Relaxed),
700            prefetched_actions: self.stats.prefetched_actions.load(Ordering::Relaxed),
701            bypasses: self.stats.bypasses.lock().unwrap().clone(),
702            remote_manifest_lookups: self.stats.remote_manifest_lookups.load(Ordering::Relaxed),
703            remote_manifest_lookup_duration_ns: self
704                .stats
705                .remote_manifest_lookup_duration_ns
706                .load(Ordering::Relaxed),
707            remote_action_lookups: self.stats.remote_action_lookups.load(Ordering::Relaxed),
708            remote_action_lookup_duration_ns: self
709                .stats
710                .remote_action_lookup_duration_ns
711                .load(Ordering::Relaxed),
712            remote_blob_requests: self.stats.remote_blob_requests.load(Ordering::Relaxed),
713            remote_blob_pack_requests: self.stats.remote_blob_pack_requests.load(Ordering::Relaxed),
714            remote_blob_pack_blobs: self.stats.remote_blob_pack_blobs.load(Ordering::Relaxed),
715            remote_blob_transfer_duration_ns: self
716                .stats
717                .remote_blob_transfer_duration_ns
718                .load(Ordering::Relaxed),
719            local_cas_write_duration_ns: self
720                .stats
721                .local_cas_write_duration_ns
722                .load(Ordering::Relaxed),
723            prefetch_runs: self.stats.prefetch_runs.load(Ordering::Relaxed),
724            prefetch_duration_ns: self.stats.prefetch_duration_ns.load(Ordering::Relaxed),
725            materialization_duration_ns: self
726                .stats
727                .materialization_duration_ns
728                .load(Ordering::Relaxed),
729            restored_output_files: self.stats.restored_output_files.load(Ordering::Relaxed),
730            restored_output_bytes: self.stats.restored_output_bytes.load(Ordering::Relaxed),
731        }
732    }
733
734    fn write_lock(&self, digest: &CacheDigest) -> Arc<tokio::sync::Mutex<()>> {
735        Self::digest_lock(&self.write_locks, digest)
736    }
737
738    fn action_lock(&self, digest: &CacheDigest) -> Arc<tokio::sync::Mutex<()>> {
739        Self::digest_lock(&self.action_locks, digest)
740    }
741
742    fn digest_lock(
743        locks: &Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>,
744        digest: &CacheDigest,
745    ) -> Arc<tokio::sync::Mutex<()>> {
746        let mut locks = locks.lock().unwrap();
747        locks.retain(|_, lock| lock.strong_count() > 0);
748        if let Some(lock) = locks.get(digest).and_then(Weak::upgrade) {
749            return lock;
750        }
751        let lock = Arc::new(tokio::sync::Mutex::new(()));
752        locks.insert(digest.clone(), Arc::downgrade(&lock));
753        lock
754    }
755
756    fn spawn_prefetch_predictions(&self, predictions: Vec<ActionPrediction>) {
757        if predictions.is_empty() || !self.remote_mode.reads() || self.remote.is_none() {
758            return;
759        }
760        let agent = self.clone();
761        let task = tokio::spawn(async move {
762            agent.prefetch_predictions(predictions.iter()).await;
763        });
764        self.prefetch_tasks.lock().unwrap().push(task);
765    }
766
767    async fn prefetch_predictions<'a>(
768        &self,
769        predictions: impl Iterator<Item = &'a ActionPrediction>,
770    ) {
771        if !self.remote_mode.reads() || self.remote.is_none() {
772            return;
773        }
774        self.stats.prefetch_runs.fetch_add(1, Ordering::Relaxed);
775        let _timer = AtomicDurationTimer::start(&self.stats.prefetch_duration_ns);
776        let mut actions = BTreeMap::new();
777        for prediction in predictions {
778            actions
779                .entry(prediction.action.clone())
780                .or_insert_with(|| prediction.adapter.clone());
781        }
782        let mut actions = actions.into_iter();
783        let mut tasks = tokio::task::JoinSet::new();
784        for _ in 0..MAX_PREFETCH_TRANSFERS {
785            let Some((action, adapter)) = actions.next() else {
786                break;
787            };
788            let agent = self.clone();
789            tasks.spawn(async move { agent.resolve_prefetch_action(action, adapter).await });
790        }
791        let mut resolved = Vec::new();
792        while !tasks.is_empty() {
793            let result = if resolved.is_empty() {
794                tasks.join_next().await
795            } else {
796                match tokio::time::timeout(PREFETCH_ACTION_BATCH_DELAY, tasks.join_next()).await {
797                    Ok(result) => result,
798                    Err(_) => {
799                        self.prefetch_resolved_actions(std::mem::take(&mut resolved))
800                            .await;
801                        continue;
802                    }
803                }
804            };
805            let Some(result) = result else {
806                break;
807            };
808            match result {
809                Ok(Ok(Some(action))) => resolved.push(action),
810                Ok(Ok(None)) => {}
811                Ok(Err(error)) => warn!("remote action prefetch failed: {error}"),
812                Err(error) => warn!("remote action prefetch task failed: {error}"),
813            }
814            if let Some((action, adapter)) = actions.next() {
815                let agent = self.clone();
816                tasks.spawn(async move { agent.resolve_prefetch_action(action, adapter).await });
817            }
818            if resolved.len() == MAX_PREFETCH_ACTION_BATCH {
819                self.prefetch_resolved_actions(std::mem::take(&mut resolved))
820                    .await;
821            }
822        }
823        if !resolved.is_empty() {
824            self.prefetch_resolved_actions(resolved).await;
825        }
826    }
827
828    #[cfg(test)]
829    async fn prefetch_action(&self, action: CacheDigest, adapter: String) -> Result<()> {
830        if let Some(action) = self.resolve_prefetch_action(action, adapter).await? {
831            self.prefetch_resolved_actions(vec![action]).await;
832        }
833        Ok(())
834    }
835
836    async fn resolve_prefetch_action(
837        &self,
838        action: CacheDigest,
839        adapter: String,
840    ) -> Result<Option<PrefetchedAction>> {
841        let remote = self
842            .remote
843            .as_ref()
844            .ok_or_else(|| eyre::eyre!("remote cache is not configured"))?;
845        let result = {
846            let lock = self.action_lock(&action);
847            let _guard = lock.lock().await;
848            if self.actions.find(&action)?.is_some() {
849                return Ok(None);
850            }
851            if let Some(result) = self
852                .pending_remote_actions
853                .lock()
854                .unwrap()
855                .get(&action)
856                .cloned()
857            {
858                result
859            } else {
860                let _prefetch_permit = self.prefetch_transfers.acquire().await?;
861                let result = {
862                    let _permit = self.remote_transfers.acquire().await?;
863                    self.get_remote_action_result(remote, &action).await?
864                };
865                let Some(result) = result else {
866                    return Ok(None);
867                };
868                self.pending_remote_actions
869                    .lock()
870                    .unwrap()
871                    .insert(action.clone(), result.clone());
872                result
873            }
874        };
875        Ok(Some(PrefetchedAction { adapter, result }))
876    }
877
878    fn prefetch_resolved_actions(&self, actions: Vec<PrefetchedAction>) -> BoxFuture<'_, ()> {
879        self.prefetch_resolved_actions_inner(actions).boxed()
880    }
881
882    async fn prefetch_resolved_actions_inner(&self, actions: Vec<PrefetchedAction>) {
883        let Some(remote) = self.remote.as_deref() else {
884            return;
885        };
886        if actions.is_empty() {
887            return;
888        }
889
890        let mut top_level = BTreeMap::new();
891        for action in &actions {
892            for digest in [
893                Some(&action.result.action),
894                action.result.metadata.as_ref(),
895                action.result.output_root.as_ref(),
896            ]
897            .into_iter()
898            .flatten()
899            {
900                top_level.insert(digest.clone(), ());
901            }
902        }
903        let mut verified = self
904            .fetch_remote_blobs(
905                remote,
906                top_level.into_keys().collect(),
907                Some(&self.prefetch_transfers),
908            )
909            .await;
910
911        let mut next = BTreeMap::new();
912        let mut pending_directories = BTreeMap::new();
913        let mut parsed_directories = BTreeMap::new();
914        let mut rustc_metadata = BTreeMap::new();
915        for action in &actions {
916            if action.adapter == "rustc"
917                && let Some(metadata_digest) = &action.result.metadata
918            {
919                match verified
920                    .get(metadata_digest)
921                    .ok_or_else(|| eyre::eyre!("remote rustc action metadata is missing"))
922                    .and_then(|path| Self::parse_rustc_metadata(path))
923                {
924                    Ok(metadata) => {
925                        queue_prefetch_digest(&verified, &mut next, metadata.stdout.clone());
926                        queue_prefetch_digest(&verified, &mut next, metadata.stderr.clone());
927                        rustc_metadata.insert(metadata_digest.clone(), metadata);
928                    }
929                    Err(error) => warn!(
930                        "remote rustc action metadata prefetch failed for {}: {error}",
931                        action.result.action.hash
932                    ),
933                }
934            }
935            if let Some(output_root) = &action.result.output_root {
936                pending_directories.insert(output_root.clone(), ());
937            }
938        }
939
940        let mut seen_directories = BTreeMap::new();
941        loop {
942            let mut following = BTreeMap::new();
943            let mut directory_limit_exceeded = false;
944            for digest in pending_directories.into_keys() {
945                following.remove(&digest);
946                if seen_directories.insert(digest.clone(), ()).is_some() {
947                    continue;
948                }
949                if seen_directories.len() > MAX_PREFETCH_DIRECTORY_OBJECTS {
950                    warn!("remote action output tree is too large to prefetch");
951                    following.clear();
952                    break;
953                }
954                match verified
955                    .get(&digest)
956                    .ok_or_else(|| eyre::eyre!("remote action output directory is missing"))
957                    .and_then(|path| Self::parse_cache_directory(path))
958                {
959                    Ok(directory) => {
960                        for file in &directory.files {
961                            queue_prefetch_digest(&verified, &mut next, file.digest.clone());
962                            if next.len() >= MAX_PREFETCH_OBJECTS_PER_WAVE {
963                                self.flush_prefetch_digest_batch(remote, &mut verified, &mut next)
964                                    .await;
965                            }
966                        }
967                        for child in &directory.directories {
968                            if !queue_prefetch_directory(
969                                &seen_directories,
970                                &mut following,
971                                child.digest.clone(),
972                                MAX_PREFETCH_DIRECTORY_OBJECTS,
973                            ) {
974                                warn!("remote action output tree is too large to prefetch");
975                                directory_limit_exceeded = true;
976                                break;
977                            }
978                            queue_prefetch_digest(&verified, &mut next, child.digest.clone());
979                            if next.len() >= MAX_PREFETCH_OBJECTS_PER_WAVE {
980                                self.flush_prefetch_digest_batch(remote, &mut verified, &mut next)
981                                    .await;
982                            }
983                        }
984                        parsed_directories.insert(digest, directory);
985                    }
986                    Err(error) => warn!(
987                        "remote action output directory prefetch failed for {}: {error}",
988                        digest.hash
989                    ),
990                }
991                if directory_limit_exceeded {
992                    following.clear();
993                    break;
994                }
995            }
996            self.flush_prefetch_digest_batch(remote, &mut verified, &mut next)
997                .await;
998            if following.is_empty() {
999                break;
1000            }
1001            pending_directories = following;
1002        }
1003
1004        for action in actions {
1005            match Self::validate_prefetched_action(
1006                &action,
1007                &verified,
1008                &rustc_metadata,
1009                &parsed_directories,
1010            ) {
1011                Ok(()) => {
1012                    if let Err(error) = self.actions.store(&action.result) {
1013                        warn!(
1014                            "remote action prefetch could not publish {}: {error}",
1015                            action.result.action.hash
1016                        );
1017                        continue;
1018                    }
1019                    self.pending_remote_actions
1020                        .lock()
1021                        .unwrap()
1022                        .remove(&action.result.action);
1023                    self.stats
1024                        .prefetched_actions
1025                        .fetch_add(1, Ordering::Relaxed);
1026                }
1027                Err(error) => warn!(
1028                    "remote action prefetch was incomplete for {}: {error}",
1029                    action.result.action.hash
1030                ),
1031            }
1032        }
1033    }
1034
1035    async fn flush_prefetch_digest_batch(
1036        &self,
1037        remote: &RemoteCacheClient,
1038        verified: &mut BTreeMap<CacheDigest, PathBuf>,
1039        pending: &mut BTreeMap<CacheDigest, ()>,
1040    ) {
1041        if pending.is_empty() {
1042            return;
1043        }
1044        let digests = std::mem::take(pending).into_keys().collect();
1045        verified.extend(
1046            self.fetch_remote_blobs(remote, digests, Some(&self.prefetch_transfers))
1047                .await,
1048        );
1049    }
1050
1051    async fn fetch_remote_blobs(
1052        &self,
1053        remote: &RemoteCacheClient,
1054        digests: Vec<CacheDigest>,
1055        prefetch_limit: Option<&tokio::sync::Semaphore>,
1056    ) -> BTreeMap<CacheDigest, PathBuf> {
1057        let mut verified = BTreeMap::new();
1058        let mut missing = BTreeMap::new();
1059        for digest in digests {
1060            match self.find_verified_blob(&digest) {
1061                Ok(Some(path)) => {
1062                    verified.insert(digest, path);
1063                }
1064                Ok(None) => {
1065                    missing.insert(digest, ());
1066                }
1067                Err(error) => warn!(
1068                    "local cache blob lookup failed for {}: {error}",
1069                    digest.hash
1070                ),
1071            }
1072        }
1073        if missing.is_empty() {
1074            return verified;
1075        }
1076
1077        let mut pack_candidates = missing.clone();
1078        while !pack_candidates.is_empty() {
1079            let requested = pack_candidates.keys().cloned().collect::<Vec<_>>();
1080            let (pack, transfer_duration_ns) = {
1081                let _prefetch_permit = match prefetch_limit {
1082                    Some(limit) => match limit.acquire().await {
1083                        Ok(permit) => Some(permit),
1084                        Err(error) => {
1085                            warn!(
1086                                "remote cache blob pack could not acquire prefetch limit: {error}"
1087                            );
1088                            break;
1089                        }
1090                    },
1091                    None => None,
1092                };
1093                let _transfer_permit = match self.remote_transfers.acquire().await {
1094                    Ok(permit) => permit,
1095                    Err(error) => {
1096                        warn!("remote cache blob pack could not acquire transfer limit: {error}");
1097                        break;
1098                    }
1099                };
1100                let transfer_started = Instant::now();
1101                let pack = remote
1102                    .get_blob_pack(&requested, self.remote_staging_dir.as_path())
1103                    .await;
1104                (pack, duration_ns(transfer_started))
1105            };
1106            let pack = match pack {
1107                Ok(Some(pack)) => pack,
1108                Ok(None) => break,
1109                Err(error) => {
1110                    atomic_saturating_add(
1111                        &self.stats.remote_blob_transfer_duration_ns,
1112                        transfer_duration_ns,
1113                    );
1114                    warn!(
1115                        "remote cache blob pack failed; falling back to individual blobs: {error}"
1116                    );
1117                    break;
1118                }
1119            };
1120            atomic_saturating_add(
1121                &self.stats.remote_blob_transfer_duration_ns,
1122                transfer_duration_ns,
1123            );
1124            atomic_saturating_add(&self.stats.remote_blob_pack_requests, pack.requests);
1125            atomic_saturating_add(&self.stats.remote_blob_pack_blobs, pack.blob_count);
1126            atomic_saturating_add(&self.stats.downloaded_bytes, pack.payload_bytes);
1127            if pack.requested.is_empty() {
1128                break;
1129            }
1130            for digest in &pack.requested {
1131                pack_candidates.remove(digest);
1132            }
1133            let mut ingests = stream::iter(pack.blobs.into_iter().map(|(digest, source)| {
1134                let digest_for_result = digest.clone();
1135                async move {
1136                    (
1137                        digest_for_result,
1138                        self.ingest_packed_blob(digest, source).await,
1139                    )
1140                }
1141            }))
1142            .buffer_unordered(MAX_PREFETCH_TRANSFERS);
1143            while let Some((digest, result)) = ingests.next().await {
1144                match result {
1145                    Ok(path) => {
1146                        missing.remove(&digest);
1147                        verified.insert(digest, path);
1148                    }
1149                    Err(error) => warn!(
1150                        "remote cache packed blob ingest failed for {}: {error}",
1151                        digest.hash
1152                    ),
1153                }
1154            }
1155        }
1156
1157        let mut transfers = stream::iter(missing.into_keys().map(|digest| {
1158            let digest_for_result = digest.clone();
1159            async move {
1160                (
1161                    digest_for_result,
1162                    self.fetch_remote_blob_with_limit(remote, &digest, prefetch_limit)
1163                        .await,
1164                )
1165            }
1166        }))
1167        .buffer_unordered(MAX_PREFETCH_TRANSFERS);
1168        while let Some((digest, result)) = transfers.next().await {
1169            match result {
1170                Ok(path) => {
1171                    verified.insert(digest, path);
1172                }
1173                Err(error) => warn!(
1174                    "remote cache blob prefetch failed for {}: {error}",
1175                    digest.hash
1176                ),
1177            }
1178        }
1179        verified
1180    }
1181
1182    async fn ingest_packed_blob(&self, digest: CacheDigest, source: PathBuf) -> Result<PathBuf> {
1183        let digest_size = digest.size;
1184        let lock = self.write_lock(&digest);
1185        let _guard = lock.lock().await;
1186        let agent = self.clone();
1187        let (path, stored, cas_duration_ns) = tokio::task::spawn_blocking(move || {
1188            if let Some(path) = agent.find_verified_blob(&digest)? {
1189                return Ok::<_, eyre::Report>((path, false, 0));
1190            }
1191            let cas_started = Instant::now();
1192            let path = agent.cas.store_verified_file(&digest, &source)?;
1193            let cas_duration_ns = duration_ns(cas_started);
1194            agent.remember_verified_blob(&digest, &path);
1195            Ok((path, true, cas_duration_ns))
1196        })
1197        .await??;
1198        atomic_saturating_add(&self.stats.local_cas_write_duration_ns, cas_duration_ns);
1199        if stored {
1200            self.stats.stores.fetch_add(1, Ordering::Relaxed);
1201            atomic_saturating_add(&self.stats.stored_bytes, digest_size);
1202        }
1203        Ok(path)
1204    }
1205
1206    fn parse_rustc_metadata(path: &Path) -> Result<RustcMetadata> {
1207        let bytes = fs::read(path)?;
1208        let metadata: RustcMetadata = serde_json::from_slice(&bytes)?;
1209        if metadata.version != 1 || metadata.kind != "rustc" || canonical_json(&metadata)? != bytes
1210        {
1211            bail!("remote rustc action metadata is invalid");
1212        }
1213        Ok(metadata)
1214    }
1215
1216    fn parse_cache_directory(path: &Path) -> Result<CacheDirectory> {
1217        let bytes = fs::read(path)?;
1218        let directory: CacheDirectory = serde_json::from_slice(&bytes)?;
1219        if directory.version != 1 || canonical_json(&directory)? != bytes {
1220            bail!("remote action output directory is invalid");
1221        }
1222        Ok(directory)
1223    }
1224
1225    #[cfg(test)]
1226    fn load_cache_directory(&self, digest: &CacheDigest) -> Result<CacheDirectory> {
1227        let path = self
1228            .find_verified_blob(digest)?
1229            .ok_or_else(|| eyre::eyre!("remote action output directory is missing"))?;
1230        Self::parse_cache_directory(&path)
1231    }
1232
1233    fn validate_prefetched_action(
1234        action: &PrefetchedAction,
1235        verified: &BTreeMap<CacheDigest, PathBuf>,
1236        rustc_metadata: &BTreeMap<CacheDigest, RustcMetadata>,
1237        directories: &BTreeMap<CacheDigest, CacheDirectory>,
1238    ) -> Result<()> {
1239        if !verified.contains_key(&action.result.action) {
1240            bail!("remote action descriptor is missing");
1241        }
1242        if let Some(metadata) = &action.result.metadata {
1243            if action.adapter == "rustc" {
1244                let metadata = rustc_metadata
1245                    .get(metadata)
1246                    .ok_or_else(|| eyre::eyre!("remote rustc action metadata is missing"))?;
1247                for digest in [&metadata.stdout, &metadata.stderr] {
1248                    if !verified.contains_key(digest) {
1249                        bail!("remote rustc action diagnostic blob is missing");
1250                    }
1251                }
1252            } else if !verified.contains_key(metadata) {
1253                bail!("remote action metadata is missing");
1254            }
1255        }
1256        let mut pending = action
1257            .result
1258            .output_root
1259            .iter()
1260            .cloned()
1261            .collect::<Vec<_>>();
1262        let mut seen = BTreeMap::new();
1263        while let Some(digest) = pending.pop() {
1264            if seen.insert(digest.clone(), ()).is_some() {
1265                continue;
1266            }
1267            if seen.len() > MAX_PREFETCH_DIRECTORY_OBJECTS {
1268                bail!("remote action output tree is too large");
1269            }
1270            let directory = directories
1271                .get(&digest)
1272                .ok_or_else(|| eyre::eyre!("remote action output directory is missing"))?;
1273            for file in &directory.files {
1274                if !verified.contains_key(&file.digest) {
1275                    bail!("remote action output file is missing");
1276                }
1277            }
1278            pending.extend(
1279                directory
1280                    .directories
1281                    .iter()
1282                    .map(|directory| directory.digest.clone()),
1283            );
1284        }
1285        Ok(())
1286    }
1287
1288    #[cfg(test)]
1289    async fn prefetch_output_tree(
1290        &self,
1291        remote: &RemoteCacheClient,
1292        output_root: &CacheDigest,
1293    ) -> Result<()> {
1294        let mut pending = vec![output_root.clone()];
1295        let mut seen = BTreeMap::new();
1296        while let Some(digest) = pending.pop() {
1297            if seen.insert(digest.clone(), ()).is_some() {
1298                continue;
1299            }
1300            if seen.len() > MAX_PREFETCH_DIRECTORY_OBJECTS {
1301                bail!("remote action output tree is too large");
1302            }
1303            self.fetch_remote_blob_with_limit(remote, &digest, Some(&self.prefetch_transfers))
1304                .await?;
1305            let directory = self.load_cache_directory(&digest)?;
1306            let mut transfers = stream::iter(directory.files.into_iter().map(|file| async move {
1307                self.fetch_remote_blob_with_limit(
1308                    remote,
1309                    &file.digest,
1310                    Some(&self.prefetch_transfers),
1311                )
1312                .await
1313                .map(|_| ())
1314            }))
1315            .buffer_unordered(MAX_PREFETCH_TRANSFERS);
1316            while let Some(result) = transfers.next().await {
1317                result?;
1318            }
1319            pending.extend(
1320                directory
1321                    .directories
1322                    .into_iter()
1323                    .map(|directory| directory.digest),
1324            );
1325        }
1326        Ok(())
1327    }
1328
1329    async fn fetch_remote_blob(
1330        &self,
1331        remote: &RemoteCacheClient,
1332        digest: &CacheDigest,
1333    ) -> Result<PathBuf> {
1334        self.fetch_remote_blob_with_limit(remote, digest, None)
1335            .await
1336    }
1337
1338    async fn fetch_remote_blob_with_limit(
1339        &self,
1340        remote: &RemoteCacheClient,
1341        digest: &CacheDigest,
1342        prefetch_limit: Option<&tokio::sync::Semaphore>,
1343    ) -> Result<PathBuf> {
1344        let lock = self.write_lock(digest);
1345        let _guard = lock.lock().await;
1346        if let Some(path) = self.find_verified_blob(digest)? {
1347            return Ok(path);
1348        }
1349        let _prefetch_permit = match prefetch_limit {
1350            Some(limit) => Some(limit.acquire().await?),
1351            None => None,
1352        };
1353        let _permit = self.remote_transfers.acquire().await?;
1354        self.stats
1355            .remote_blob_requests
1356            .fetch_add(1, Ordering::Relaxed);
1357        let transfer_timer =
1358            AtomicDurationTimer::start(&self.stats.remote_blob_transfer_duration_ns);
1359        let temporary = remote
1360            .get_blob_file(digest, self.remote_staging_dir.as_path())
1361            .await?;
1362        drop(transfer_timer);
1363        let _cas_timer = AtomicDurationTimer::start(&self.stats.local_cas_write_duration_ns);
1364        let path = self.cas.store_verified_file(digest, temporary.path())?;
1365        self.remember_verified_blob(digest, &path);
1366        self.stats.stores.fetch_add(1, Ordering::Relaxed);
1367        self.stats
1368            .stored_bytes
1369            .fetch_add(digest.size, Ordering::Relaxed);
1370        self.stats
1371            .downloaded_bytes
1372            .fetch_add(digest.size, Ordering::Relaxed);
1373        Ok(path)
1374    }
1375
1376    async fn respond(&self, request: AgentRequest) -> AgentResponse {
1377        let result = match request {
1378            AgentRequest::FindBlob { digest } => self.find_blob(&digest).await,
1379            AgentRequest::FindBlobs { digests } => self.find_blobs(digests).await,
1380            AgentRequest::StoreBlob { digest, source } => self.store_blob(&digest, &source).await,
1381            AgentRequest::FindActionResult { action } => {
1382                self.stats.lookups.fetch_add(1, Ordering::Relaxed);
1383                self.find_action_result(&action).await
1384            }
1385            AgentRequest::RecordActionHit { action, restore } => {
1386                self.record_action_hit(&action, restore)
1387            }
1388            AgentRequest::RecordBypass { kind } => {
1389                *self.stats.bypasses.lock().unwrap().entry(kind).or_insert(0) += 1;
1390                Ok(AgentResponse::BypassRecorded)
1391            }
1392            AgentRequest::RecordActionVerification { matched, restore } => {
1393                self.record_materialization(restore);
1394                self.stats.verifications.fetch_add(1, Ordering::Relaxed);
1395                if !matched {
1396                    self.stats.divergences.fetch_add(1, Ordering::Relaxed);
1397                }
1398                Ok(AgentResponse::ActionVerificationRecorded)
1399            }
1400            AgentRequest::StoreActionResult { result } => self.store_action_result(&result).await,
1401            AgentRequest::FindActionPrediction { task, invocation } => {
1402                self.find_action_prediction(&task, &invocation)
1403            }
1404            AgentRequest::RecordActionPrediction { task, prediction } => {
1405                self.record_action_prediction(&task, prediction)
1406            }
1407            AgentRequest::FindExecutableIdentity {
1408                executable,
1409                environment,
1410            } => self.find_executable_identity(executable, environment),
1411            AgentRequest::StoreExecutableIdentity {
1412                executable,
1413                environment,
1414                stdout,
1415            } => self.store_executable_identity(executable, environment, stdout),
1416            AgentRequest::Hello { .. } => {
1417                Err(eyre::eyre!("hello is only valid as the first request"))
1418            }
1419        };
1420        result.unwrap_or_else(|error| AgentResponse::Error {
1421            message: error.to_string(),
1422        })
1423    }
1424
1425    async fn find_blob(&self, digest: &CacheDigest) -> Result<AgentResponse> {
1426        if let Some(path) = self.find_verified_blob(digest)? {
1427            return Ok(AgentResponse::Blob { path: Some(path) });
1428        }
1429        if !self.remote_mode.reads() {
1430            return Ok(AgentResponse::Blob { path: None });
1431        }
1432        let Some(remote) = &self.remote else {
1433            return Ok(AgentResponse::Blob { path: None });
1434        };
1435        match self.fetch_remote_blob(remote, digest).await {
1436            Ok(path) => Ok(AgentResponse::Blob { path: Some(path) }),
1437            Err(error) => {
1438                warn!(
1439                    "remote cache blob lookup failed for {}: {error}",
1440                    digest.hash
1441                );
1442                Ok(AgentResponse::Blob { path: None })
1443            }
1444        }
1445    }
1446
1447    async fn find_blobs(&self, digests: Vec<CacheDigest>) -> Result<AgentResponse> {
1448        let mut paths = BTreeMap::new();
1449        let mut missing = Vec::new();
1450        for digest in &digests {
1451            match self.find_verified_blob(digest)? {
1452                Some(path) => {
1453                    paths.insert(digest.clone(), path);
1454                }
1455                None => {
1456                    missing.push(digest.clone());
1457                }
1458            }
1459        }
1460
1461        if !missing.is_empty()
1462            && self.remote_mode.reads()
1463            && let Some(remote) = &self.remote
1464        {
1465            paths.extend(self.fetch_remote_blobs(remote, missing, None).await);
1466        }
1467
1468        Ok(AgentResponse::Blobs {
1469            paths: digests
1470                .into_iter()
1471                .map(|digest| paths.get(&digest).cloned())
1472                .collect(),
1473        })
1474    }
1475
1476    async fn store_blob(&self, digest: &CacheDigest, source: &Path) -> Result<AgentResponse> {
1477        let remote = if self.remote_mode.writes() {
1478            self.remote.as_deref()
1479        } else {
1480            None
1481        };
1482        let path = {
1483            let lock = self.write_lock(digest);
1484            let _guard = lock.lock().await;
1485            if let Some(path) = self.find_verified_blob(digest)? {
1486                path
1487            } else {
1488                let path = self.cas.store_file(digest, source)?;
1489                self.remember_verified_blob(digest, &path);
1490                self.stats.stores.fetch_add(1, Ordering::Relaxed);
1491                self.stats
1492                    .stored_bytes
1493                    .fetch_add(digest.size, Ordering::Relaxed);
1494                path
1495            }
1496        };
1497        if let Some(remote) = remote {
1498            let _permit = self.remote_transfers.acquire().await?;
1499            if let Err(error) = remote
1500                .put_blob(&BlobUpload {
1501                    digest: digest.clone(),
1502                    source: BlobSource::Path(path.clone()),
1503                })
1504                .await
1505            {
1506                warn!(
1507                    "remote cache blob upload failed for {}: {error}",
1508                    digest.hash
1509                );
1510            } else {
1511                self.stats
1512                    .uploaded_bytes
1513                    .fetch_add(digest.size, Ordering::Relaxed);
1514            }
1515        }
1516        Ok(AgentResponse::Stored { path })
1517    }
1518
1519    fn find_verified_blob(&self, digest: &CacheDigest) -> Result<Option<PathBuf>> {
1520        let remembered = self.verified_blobs.lock().unwrap().get(digest).cloned();
1521        if let Some(path) = remembered {
1522            if digest.matches_file(&path).unwrap_or(false) {
1523                return Ok(Some(path));
1524            }
1525            self.verified_blobs.lock().unwrap().remove(digest);
1526        }
1527        let path = self.cas.find(digest)?;
1528        if let Some(path) = &path {
1529            self.remember_verified_blob(digest, path);
1530        }
1531        Ok(path)
1532    }
1533
1534    fn remember_verified_blob(&self, digest: &CacheDigest, path: &Path) {
1535        self.verified_blobs
1536            .lock()
1537            .unwrap()
1538            .insert(digest.clone(), path.to_path_buf());
1539    }
1540
1541    async fn find_action_result(&self, action: &CacheDigest) -> Result<AgentResponse> {
1542        if let Some(result) = self.actions.find(action)? {
1543            return Ok(AgentResponse::ActionResult {
1544                result: Some(result),
1545            });
1546        }
1547        if !self.remote_mode.reads() {
1548            return Ok(AgentResponse::ActionResult { result: None });
1549        }
1550        let Some(remote) = &self.remote else {
1551            return Ok(AgentResponse::ActionResult { result: None });
1552        };
1553        let lock = self.action_lock(action);
1554        let _guard = lock.lock().await;
1555        if let Some(result) = self.actions.find(action)? {
1556            return Ok(AgentResponse::ActionResult {
1557                result: Some(result),
1558            });
1559        }
1560        if let Some(result) = self
1561            .pending_remote_actions
1562            .lock()
1563            .unwrap()
1564            .get(action)
1565            .cloned()
1566        {
1567            return Ok(AgentResponse::ActionResult {
1568                result: Some(result),
1569            });
1570        }
1571        let _permit = self.remote_transfers.acquire().await?;
1572        match self.get_remote_action_result(remote, action).await {
1573            Ok(Some(result)) => {
1574                self.pending_remote_actions
1575                    .lock()
1576                    .unwrap()
1577                    .insert(action.clone(), result.clone());
1578                Ok(AgentResponse::ActionResult {
1579                    result: Some(result),
1580                })
1581            }
1582            Ok(None) => Ok(AgentResponse::ActionResult { result: None }),
1583            Err(error) => {
1584                warn!(
1585                    "remote cache action lookup failed for {}: {error}",
1586                    action.hash
1587                );
1588                Ok(AgentResponse::ActionResult { result: None })
1589            }
1590        }
1591    }
1592
1593    async fn store_action_result(&self, result: &RemoteActionResult) -> Result<AgentResponse> {
1594        let path = self.actions.store(result)?;
1595        if self.remote_mode.writes()
1596            && let Some(remote) = &self.remote
1597        {
1598            let _permit = self.remote_transfers.acquire().await?;
1599            if let Err(error) = remote.put_action_result(result).await {
1600                warn!(
1601                    "remote cache action upload failed for {}: {error}",
1602                    result.action.hash
1603                );
1604            }
1605        }
1606        Ok(AgentResponse::ActionStored { path })
1607    }
1608
1609    async fn get_remote_action_result(
1610        &self,
1611        remote: &RemoteCacheClient,
1612        action: &CacheDigest,
1613    ) -> Result<Option<RemoteActionResult>> {
1614        self.stats
1615            .remote_action_lookups
1616            .fetch_add(1, Ordering::Relaxed);
1617        let _timer = AtomicDurationTimer::start(&self.stats.remote_action_lookup_duration_ns);
1618        remote.get_action_result(action).await
1619    }
1620
1621    fn record_action_hit(
1622        &self,
1623        action: &CacheDigest,
1624        restore: RestoreStats,
1625    ) -> Result<AgentResponse> {
1626        if self.actions.find(action)?.is_none() {
1627            let pending = self.pending_remote_actions.lock().unwrap().remove(action);
1628            if let Some(result) = pending {
1629                self.actions.store(&result)?;
1630            } else {
1631                bail!("cannot record a hit for a missing action result");
1632            }
1633        }
1634        self.record_restore(restore);
1635        self.stats.hits.fetch_add(1, Ordering::Relaxed);
1636        Ok(AgentResponse::ActionHitRecorded)
1637    }
1638
1639    fn record_restore(&self, restore: RestoreStats) {
1640        self.record_materialization(restore);
1641        atomic_saturating_add(&self.stats.restored_output_files, restore.output_files);
1642        atomic_saturating_add(&self.stats.restored_output_bytes, restore.output_bytes);
1643    }
1644
1645    fn record_materialization(&self, restore: RestoreStats) {
1646        atomic_saturating_add(&self.stats.materialization_duration_ns, restore.duration_ns);
1647    }
1648
1649    fn find_action_prediction(
1650        &self,
1651        task: &str,
1652        invocation: &CacheDigest,
1653    ) -> Result<AgentResponse> {
1654        validate_task_identity(task)?;
1655        invocation.validate()?;
1656        let prediction = self
1657            .task_actions
1658            .lock()
1659            .unwrap()
1660            .get(task)
1661            .and_then(|state| state.predictions.get(invocation))
1662            .cloned();
1663        Ok(AgentResponse::ActionPrediction { prediction })
1664    }
1665
1666    fn record_action_prediction(
1667        &self,
1668        task: &str,
1669        prediction: ActionPrediction,
1670    ) -> Result<AgentResponse> {
1671        validate_task_identity(task)?;
1672        validate_action_prediction(&prediction)?;
1673        let mut tasks = self.task_actions.lock().unwrap();
1674        let state = tasks.entry(task.to_string()).or_default();
1675        if !state.predictions.contains_key(&prediction.invocation)
1676            && state.predictions.len() >= MAX_TASK_ACTION_PREDICTIONS
1677        {
1678            bail!("task action manifest contains too many predictions");
1679        }
1680        state
1681            .predictions
1682            .insert(prediction.invocation.clone(), prediction);
1683        Ok(AgentResponse::ActionPredictionRecorded)
1684    }
1685
1686    fn executable_identity_key(
1687        &self,
1688        executable: PathBuf,
1689        environment: BTreeMap<String, Option<String>>,
1690    ) -> Result<ExecutableIdentityKey> {
1691        if !environment
1692            .keys()
1693            .all(|name| matches!(name.as_str(), "RUSTUP_HOME" | "RUSTUP_TOOLCHAIN"))
1694        {
1695            bail!("executable identity contains an unsupported environment variable");
1696        }
1697        Ok(ExecutableIdentityKey {
1698            executable,
1699            environment,
1700        })
1701    }
1702
1703    fn find_executable_identity(
1704        &self,
1705        executable: PathBuf,
1706        environment: BTreeMap<String, Option<String>>,
1707    ) -> Result<AgentResponse> {
1708        let key = self.executable_identity_key(executable, environment)?;
1709        let stdout = self
1710            .executable_identities
1711            .lock()
1712            .unwrap()
1713            .get(&key)
1714            .cloned();
1715        Ok(AgentResponse::ExecutableIdentity { stdout })
1716    }
1717
1718    fn store_executable_identity(
1719        &self,
1720        executable: PathBuf,
1721        environment: BTreeMap<String, Option<String>>,
1722        stdout: Vec<u8>,
1723    ) -> Result<AgentResponse> {
1724        if stdout.len() > MAX_EXECUTABLE_IDENTITY_SIZE {
1725            bail!("executable identity exceeds {MAX_EXECUTABLE_IDENTITY_SIZE} bytes");
1726        }
1727        let key = self.executable_identity_key(executable, environment)?;
1728        let mut identities = self.executable_identities.lock().unwrap();
1729        let is_new = !identities.contains_key(&key);
1730        let previous_size = identities.get(&key).map_or(0, Vec::len);
1731        if is_new && identities.len() >= MAX_EXECUTABLE_IDENTITIES {
1732            bail!("executable identity cache contains too many entries");
1733        }
1734        let retained_bytes = identities.values().map(Vec::len).sum::<usize>();
1735        if retained_bytes - previous_size + stdout.len() > MAX_EXECUTABLE_IDENTITY_BYTES {
1736            bail!("executable identity cache contains too many bytes");
1737        }
1738        identities.insert(key, stdout.clone());
1739        Ok(AgentResponse::ExecutableIdentity {
1740            stdout: Some(stdout),
1741        })
1742    }
1743
1744    /// Serve newline-delimited protocol requests on an authenticated session stream.
1745    pub async fn handle_connection<S>(&self, stream: S) -> Result<()>
1746    where
1747        S: AsyncRead + AsyncWrite + Unpin,
1748    {
1749        let (reader, mut writer) = tokio::io::split(stream);
1750        let mut reader = BufReader::new(reader);
1751        let hello = read_request(&mut reader)
1752            .await?
1753            .ok_or_else(|| eyre::eyre!("connection closed before the agent handshake"))?;
1754        let request: AgentRequest = serde_json::from_str(&hello)?;
1755        match request {
1756            AgentRequest::Hello {
1757                protocol,
1758                client_version,
1759            } if protocol == AGENT_PROTOCOL_VERSION && client_version == self.version.as_ref() => {}
1760            AgentRequest::Hello { protocol, .. } if protocol != AGENT_PROTOCOL_VERSION => {
1761                send_response(
1762                    &mut writer,
1763                    &AgentResponse::Error {
1764                        message: format!(
1765                            "unsupported agent protocol {protocol}; expected {AGENT_PROTOCOL_VERSION}"
1766                        ),
1767                    },
1768                )
1769                .await?;
1770                return Ok(());
1771            }
1772            AgentRequest::Hello { client_version, .. } => {
1773                send_response(
1774                    &mut writer,
1775                    &AgentResponse::Error {
1776                        message: format!(
1777                            "cache client {client_version} does not match agent {}",
1778                            self.version
1779                        ),
1780                    },
1781                )
1782                .await?;
1783                return Ok(());
1784            }
1785            _ => bail!("the first agent request must be hello"),
1786        }
1787        send_response(
1788            &mut writer,
1789            &AgentResponse::Hello {
1790                protocol: AGENT_PROTOCOL_VERSION,
1791                agent_version: self.version.to_string(),
1792            },
1793        )
1794        .await?;
1795
1796        while let Some(line) = read_request(&mut reader).await? {
1797            let response = match serde_json::from_str(&line) {
1798                Ok(request) => self.respond(request).await,
1799                Err(error) => AgentResponse::Error {
1800                    message: format!("invalid agent request: {error}"),
1801                },
1802            };
1803            send_response(&mut writer, &response).await?;
1804        }
1805        Ok(())
1806    }
1807}
1808
1809/// Read one newline-delimited request, refusing one that grows past the cap.
1810///
1811/// Any process running as this user can open the session socket, so a request
1812/// that never terminates its line must not be able to grow the agent's memory
1813/// without bound.
1814async fn read_request<R>(reader: &mut R) -> Result<Option<String>>
1815where
1816    R: AsyncBufRead + Unpin,
1817{
1818    let mut line = Vec::new();
1819    loop {
1820        let available = reader.fill_buf().await?;
1821        if available.is_empty() {
1822            break;
1823        }
1824        let (consumed, complete) = match available.iter().position(|byte| *byte == b'\n') {
1825            Some(index) => (index, true),
1826            None => (available.len(), false),
1827        };
1828        if line.len() + consumed > MAX_REQUEST_BYTES {
1829            bail!("agent request exceeded {MAX_REQUEST_BYTES} bytes");
1830        }
1831        line.extend_from_slice(&available[..consumed]);
1832        // The newline itself is consumed but never kept.
1833        reader.consume(consumed + usize::from(complete));
1834        if complete {
1835            return Ok(Some(String::from_utf8(line)?));
1836        }
1837    }
1838    if line.is_empty() {
1839        Ok(None)
1840    } else {
1841        Ok(Some(String::from_utf8(line)?))
1842    }
1843}
1844
1845fn validate_task_identity(task: &str) -> Result<()> {
1846    if task.len() != 64
1847        || !task
1848            .bytes()
1849            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1850    {
1851        bail!("invalid task action identity");
1852    }
1853    Ok(())
1854}
1855
1856fn validate_action_prediction(prediction: &ActionPrediction) -> Result<()> {
1857    prediction.invocation.validate()?;
1858    prediction.action.validate()?;
1859    if prediction.adapter.is_empty()
1860        || !prediction
1861            .adapter
1862            .bytes()
1863            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1864    {
1865        bail!("invalid action prediction adapter");
1866    }
1867    if prediction.payload.len() > MAX_ACTION_PREDICTION_PAYLOAD {
1868        bail!("action prediction payload is too large");
1869    }
1870    serde_json::from_str::<serde_json::Value>(&prediction.payload)?;
1871    Ok(())
1872}
1873
1874fn validate_task_manifest(manifest: &TaskActionManifest, task: &str) -> Result<()> {
1875    if manifest.version != TASK_ACTION_MANIFEST_VERSION || manifest.task != task {
1876        bail!("task action manifest has an invalid identity");
1877    }
1878    if manifest.predictions.len() > MAX_TASK_ACTION_PREDICTIONS {
1879        bail!("task action manifest contains too many predictions");
1880    }
1881    let mut invocations = BTreeMap::new();
1882    for prediction in &manifest.predictions {
1883        validate_action_prediction(prediction)?;
1884        if invocations.insert(&prediction.invocation, ()).is_some() {
1885            bail!("task action manifest contains duplicate predictions");
1886        }
1887    }
1888    Ok(())
1889}
1890
1891fn merge_task_manifests(
1892    task: &str,
1893    base: Option<TaskActionManifest>,
1894    update: TaskActionManifest,
1895) -> Result<TaskActionManifest> {
1896    validate_task_manifest(&update, task)?;
1897    let mut predictions = BTreeMap::new();
1898    if let Some(base) = base {
1899        validate_task_manifest(&base, task)?;
1900        predictions.extend(
1901            base.predictions
1902                .into_iter()
1903                .map(|prediction| (prediction.invocation.clone(), prediction)),
1904        );
1905    }
1906    predictions.extend(
1907        update
1908            .predictions
1909            .into_iter()
1910            .map(|prediction| (prediction.invocation.clone(), prediction)),
1911    );
1912    let manifest = TaskActionManifest {
1913        version: TASK_ACTION_MANIFEST_VERSION,
1914        task: task.to_owned(),
1915        predictions: predictions.into_values().collect(),
1916    };
1917    validate_task_manifest(&manifest, task)?;
1918    Ok(manifest)
1919}
1920
1921fn merge_remote_task_manifest(
1922    task: &str,
1923    remote: TaskActionManifest,
1924    local: TaskActionManifest,
1925) -> (TaskActionManifest, bool) {
1926    match merge_task_manifests(task, Some(remote), local.clone()) {
1927        Ok(manifest) => (manifest, true),
1928        Err(error) => {
1929            warn!("remote task action manifest merge failed for {task}: {error}");
1930            (local, false)
1931        }
1932    }
1933}
1934
1935async fn send_response(
1936    writer: &mut (impl AsyncWrite + Unpin),
1937    response: &AgentResponse,
1938) -> Result<()> {
1939    let mut encoded = serde_json::to_vec(response)?;
1940    encoded.push(b'\n');
1941    writer.write_all(&encoded).await?;
1942    writer.flush().await?;
1943    Ok(())
1944}
1945
1946#[cfg(test)]
1947mod tests {
1948    use super::*;
1949    use crate::ACTION_RESULT_MEDIA_TYPE;
1950    use std::time::Duration;
1951
1952    #[test]
1953    fn directory_queue_counts_only_unique_unseen_nodes() {
1954        let shared = CacheDigest::blake3(b"shared");
1955        let first = CacheDigest::blake3(b"first");
1956        let second = CacheDigest::blake3(b"second");
1957        let overflow = CacheDigest::blake3(b"overflow");
1958        let seen = BTreeMap::from([(shared.clone(), ())]);
1959        let mut pending = BTreeMap::new();
1960
1961        assert!(queue_prefetch_directory(&seen, &mut pending, shared, 3));
1962        assert!(pending.is_empty());
1963        assert!(queue_prefetch_directory(
1964            &seen,
1965            &mut pending,
1966            first.clone(),
1967            3
1968        ));
1969        assert!(queue_prefetch_directory(&seen, &mut pending, first, 3));
1970        assert!(queue_prefetch_directory(&seen, &mut pending, second, 3));
1971        assert!(!queue_prefetch_directory(&seen, &mut pending, overflow, 3));
1972        assert_eq!(pending.len(), 2);
1973    }
1974
1975    async fn handshake(stream: &mut (impl AsyncRead + AsyncWrite + Unpin), version: &str) {
1976        let request = AgentRequest::Hello {
1977            protocol: AGENT_PROTOCOL_VERSION,
1978            client_version: version.to_string(),
1979        };
1980        let mut encoded = serde_json::to_vec(&request).unwrap();
1981        encoded.push(b'\n');
1982        stream.write_all(&encoded).await.unwrap();
1983        stream.flush().await.unwrap();
1984        let mut response = String::new();
1985        BufReader::new(stream)
1986            .read_line(&mut response)
1987            .await
1988            .unwrap();
1989        assert!(matches!(
1990            serde_json::from_str(&response).unwrap(),
1991            AgentResponse::Hello { .. }
1992        ));
1993    }
1994
1995    #[tokio::test]
1996    async fn rejects_a_request_that_never_ends_its_line() {
1997        let directory = tempfile::tempdir().unwrap();
1998        let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
1999        let (mut client, server) = tokio::io::duplex(64 * 1024);
2000        let task = tokio::spawn(async move { agent.handle_connection(server).await });
2001
2002        handshake(&mut client, "test-version").await;
2003        // Never send a newline: the agent must give up rather than buffer
2004        // whatever a peer is willing to write.
2005        let filler = vec![b'x'; 64 * 1024];
2006        let mut written = 0usize;
2007        while written <= MAX_REQUEST_BYTES {
2008            if client.write_all(&filler).await.is_err() {
2009                break;
2010            }
2011            written += filler.len();
2012        }
2013
2014        let error = task.await.unwrap().err().unwrap();
2015        assert!(
2016            error.to_string().contains("exceeded"),
2017            "unexpected error: {error}"
2018        );
2019    }
2020
2021    #[tokio::test]
2022    async fn counts_bypasses_by_reason() {
2023        let directory = tempfile::tempdir().unwrap();
2024        let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
2025
2026        for kind in [
2027            "unsupported-crate-type",
2028            "unsupported-crate-type",
2029            "incremental",
2030        ] {
2031            agent
2032                .respond(AgentRequest::RecordBypass { kind: kind.into() })
2033                .await;
2034        }
2035
2036        let stats = agent.stats();
2037        assert_eq!(stats.bypasses.get("unsupported-crate-type"), Some(&2));
2038        assert_eq!(stats.bypasses.get("incremental"), Some(&1));
2039    }
2040
2041    #[tokio::test]
2042    async fn handshake_and_blob_round_trip() {
2043        let directory = tempfile::tempdir().unwrap();
2044        let source = directory.path().join("source");
2045        std::fs::write(&source, b"cached object").unwrap();
2046        let digest = CacheDigest::blake3(b"cached object");
2047        let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
2048        let (mut client, server) = tokio::io::duplex(16 * 1024);
2049        let server_agent = agent.clone();
2050        let task = tokio::spawn(async move { server_agent.handle_connection(server).await });
2051
2052        handshake(&mut client, "test-version").await;
2053        let request = AgentRequest::StoreBlob {
2054            digest: digest.clone(),
2055            source,
2056        };
2057        let mut encoded = serde_json::to_vec(&request).unwrap();
2058        encoded.push(b'\n');
2059        client.write_all(&encoded).await.unwrap();
2060        let mut response = String::new();
2061        BufReader::new(&mut client)
2062            .read_line(&mut response)
2063            .await
2064            .unwrap();
2065        assert!(matches!(
2066            serde_json::from_str(&response).unwrap(),
2067            AgentResponse::Stored { .. }
2068        ));
2069        drop(client);
2070        task.await.unwrap().unwrap();
2071        assert_eq!(
2072            agent.stats(),
2073            AgentStats {
2074                stores: 1,
2075                stored_bytes: digest.size,
2076                ..AgentStats::default()
2077            }
2078        );
2079    }
2080
2081    #[test]
2082    fn remembered_blobs_reject_same_size_corruption() {
2083        let directory = tempfile::tempdir().unwrap();
2084        let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
2085        let digest = CacheDigest::blake3(b"cached object");
2086        let path = agent.cas.store_bytes(&digest, b"cached object").unwrap();
2087        assert_eq!(
2088            agent.find_verified_blob(&digest).unwrap(),
2089            Some(path.clone())
2090        );
2091
2092        std::fs::write(&path, b"broken object").unwrap();
2093
2094        assert!(agent.find_verified_blob(&digest).is_err());
2095        assert!(!agent.verified_blobs.lock().unwrap().contains_key(&digest));
2096    }
2097
2098    #[tokio::test]
2099    async fn publishes_a_complete_action_result() {
2100        let directory = tempfile::tempdir().unwrap();
2101        let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
2102        let action = CacheDigest::blake3(b"action");
2103        let metadata = CacheDigest::blake3(b"metadata");
2104        let output_root = CacheDigest::blake3(b"directory");
2105        for (digest, contents) in [
2106            (&action, b"action".as_slice()),
2107            (&metadata, b"metadata".as_slice()),
2108            (&output_root, b"directory".as_slice()),
2109        ] {
2110            agent.cas.store_bytes(digest, contents).unwrap();
2111        }
2112        let response = agent
2113            .respond(AgentRequest::StoreActionResult {
2114                result: RemoteActionResult {
2115                    action: action.clone(),
2116                    metadata: Some(metadata),
2117                    output_root: Some(output_root),
2118                    version: 1,
2119                },
2120            })
2121            .await;
2122        assert!(matches!(response, AgentResponse::ActionStored { .. }));
2123        let response = agent
2124            .respond(AgentRequest::FindActionResult {
2125                action: action.clone(),
2126            })
2127            .await;
2128        assert!(matches!(
2129            response,
2130            AgentResponse::ActionResult {
2131                result: Some(result)
2132            } if result.action == action
2133        ));
2134        assert!(matches!(
2135            agent
2136                .respond(AgentRequest::RecordActionHit {
2137                    action: action.clone(),
2138                    restore: RestoreStats {
2139                        duration_ns: 7,
2140                        output_files: 2,
2141                        output_bytes: 11,
2142                    },
2143                })
2144                .await,
2145            AgentResponse::ActionHitRecorded
2146        ));
2147        assert_eq!(
2148            agent.stats(),
2149            AgentStats {
2150                lookups: 1,
2151                hits: 1,
2152                materialization_duration_ns: 7,
2153                restored_output_files: 2,
2154                restored_output_bytes: 11,
2155                ..AgentStats::default()
2156            }
2157        );
2158    }
2159
2160    #[tokio::test]
2161    async fn missing_action_result_is_a_cache_miss() {
2162        let directory = tempfile::tempdir().unwrap();
2163        let agent = CacheAgent::new(directory.path(), "test-version");
2164        let action = CacheDigest::blake3(b"missing action");
2165        let response = agent
2166            .respond(AgentRequest::FindActionResult {
2167                action: action.clone(),
2168            })
2169            .await;
2170
2171        assert!(matches!(
2172            response,
2173            AgentResponse::ActionResult { result: None }
2174        ));
2175        assert!(matches!(
2176            agent
2177                .respond(AgentRequest::RecordActionHit {
2178                    action,
2179                    restore: RestoreStats::default(),
2180                })
2181                .await,
2182            AgentResponse::Error { .. }
2183        ));
2184        assert_eq!(
2185            agent.stats(),
2186            AgentStats {
2187                lookups: 1,
2188                ..AgentStats::default()
2189            }
2190        );
2191
2192        assert!(matches!(
2193            agent
2194                .respond(AgentRequest::RecordActionVerification {
2195                    matched: false,
2196                    restore: RestoreStats {
2197                        duration_ns: 7,
2198                        output_files: 2,
2199                        output_bytes: 11,
2200                    },
2201                })
2202                .await,
2203            AgentResponse::ActionVerificationRecorded
2204        ));
2205        assert_eq!(agent.stats().verifications, 1);
2206        assert_eq!(agent.stats().divergences, 1);
2207        assert_eq!(agent.stats().materialization_duration_ns, 7);
2208        assert_eq!(agent.stats().restored_output_files, 0);
2209        assert_eq!(agent.stats().restored_output_bytes, 0);
2210    }
2211
2212    #[tokio::test]
2213    async fn coalesces_repeated_remote_action_lookups() {
2214        let directory = tempfile::tempdir().unwrap();
2215        let mut server = mockito::Server::new_async().await;
2216        let action = CacheDigest::blake3(b"remote action");
2217        let result = RemoteActionResult {
2218            action: action.clone(),
2219            metadata: None,
2220            output_root: None,
2221            version: 1,
2222        };
2223        let remote = server
2224            .mock("GET", action_path(&action).as_str())
2225            .with_status(200)
2226            .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
2227            .with_body(serde_json::to_vec(&result).unwrap())
2228            .expect(1)
2229            .create_async()
2230            .await;
2231        let agent = remote_agent(
2232            &server,
2233            directory.path().join("reader"),
2234            RemoteCacheMode::ReadOnly,
2235        );
2236
2237        for _ in 0..2 {
2238            assert!(matches!(
2239                agent
2240                    .respond(AgentRequest::FindActionResult {
2241                        action: action.clone(),
2242                    })
2243                    .await,
2244                AgentResponse::ActionResult {
2245                    result: Some(found)
2246                } if found == result
2247            ));
2248        }
2249        remote.assert_async().await;
2250    }
2251
2252    #[tokio::test]
2253    async fn publishes_only_successfully_committed_task_action_manifests() {
2254        let directory = tempfile::tempdir().unwrap();
2255        let cache = directory.path().join("cache");
2256        let task = "a".repeat(64);
2257        let first_invocation = CacheDigest::blake3(b"first invocation");
2258        let first = ActionPrediction {
2259            invocation: first_invocation.clone(),
2260            action: CacheDigest::blake3(b"first action"),
2261            adapter: "rustc".into(),
2262            payload: "{}".into(),
2263        };
2264
2265        let agent = CacheAgent::new(&cache, "test-version");
2266        let first_run = agent.begin_task(&task).await.unwrap();
2267        assert!(matches!(
2268            agent
2269                .respond(AgentRequest::RecordActionPrediction {
2270                    task: first_run.clone(),
2271                    prediction: first.clone(),
2272                })
2273                .await,
2274            AgentResponse::ActionPredictionRecorded
2275        ));
2276        agent.commit_task(&first_run).await.unwrap();
2277
2278        let uncommitted = CacheAgent::new(&cache, "test-version");
2279        let uncommitted_run = uncommitted.begin_task(&task).await.unwrap();
2280        let second_invocation = CacheDigest::blake3(b"second invocation");
2281        assert!(matches!(
2282            uncommitted
2283                .respond(AgentRequest::RecordActionPrediction {
2284                    task: uncommitted_run,
2285                    prediction: ActionPrediction {
2286                        invocation: second_invocation.clone(),
2287                        action: CacheDigest::blake3(b"second action"),
2288                        adapter: "rustc".into(),
2289                        payload: "{}".into(),
2290                    },
2291                })
2292                .await,
2293            AgentResponse::ActionPredictionRecorded
2294        ));
2295
2296        let next_session = CacheAgent::new(&cache, "test-version");
2297        let next_run = next_session.begin_task(&task).await.unwrap();
2298        assert!(matches!(
2299            next_session
2300                .respond(AgentRequest::FindActionPrediction {
2301                    task: next_run.clone(),
2302                    invocation: first_invocation,
2303                })
2304                .await,
2305            AgentResponse::ActionPrediction {
2306                prediction: Some(prediction)
2307            } if prediction == first
2308        ));
2309        assert!(matches!(
2310            next_session
2311                .respond(AgentRequest::FindActionPrediction {
2312                    task: next_run,
2313                    invocation: second_invocation,
2314                })
2315                .await,
2316            AgentResponse::ActionPrediction { prediction: None }
2317        ));
2318
2319        let corrupt_task = "b".repeat(64);
2320        fs::create_dir_all(next_session.manifest_dir.as_path()).unwrap();
2321        fs::write(next_session.task_manifest_path(&corrupt_task), b"not json").unwrap();
2322        assert!(next_session.begin_task(&corrupt_task).await.is_err());
2323        let corrupt_run = "c".repeat(64);
2324        next_session.task_actions.lock().unwrap().insert(
2325            corrupt_run.clone(),
2326            TaskActionState {
2327                manifest: corrupt_task.clone(),
2328                ..TaskActionState::default()
2329            },
2330        );
2331        assert!(matches!(
2332            next_session
2333                .respond(AgentRequest::RecordActionPrediction {
2334                    task: corrupt_run.clone(),
2335                    prediction: first,
2336                })
2337                .await,
2338            AgentResponse::ActionPredictionRecorded
2339        ));
2340        assert!(next_session.commit_task(&corrupt_run).await.is_err());
2341        assert_eq!(
2342            fs::read(next_session.task_manifest_path(&corrupt_task)).unwrap(),
2343            b"not json"
2344        );
2345    }
2346
2347    #[tokio::test]
2348    async fn round_trips_task_actions_between_fresh_local_caches() {
2349        let directory = tempfile::tempdir().unwrap();
2350        let mut server = mockito::Server::new_async().await;
2351        let task = "e".repeat(64);
2352        let invocation = CacheDigest::blake3(b"remote invocation");
2353        let action_bytes = canonical_json(&serde_json::json!({"kind":"rustc"})).unwrap();
2354        let stdout_bytes = b"cached stdout".to_vec();
2355        let stderr_bytes = b"cached stderr".to_vec();
2356        let artifact_bytes = b"cached artifact".to_vec();
2357        let stdout = CacheDigest::blake3(&stdout_bytes);
2358        let stderr = CacheDigest::blake3(&stderr_bytes);
2359        let artifact = CacheDigest::blake3(&artifact_bytes);
2360        let metadata_bytes = canonical_json(&RustcMetadata {
2361            version: 1,
2362            kind: "rustc".into(),
2363            stdout: stdout.clone(),
2364            stderr: stderr.clone(),
2365        })
2366        .unwrap();
2367        let directory_bytes = canonical_json(&serde_json::json!({
2368            "directories":[],
2369            "files":[{"digest":artifact,"executable":false,"mode":420,"name":"artifact"}],
2370            "symlinks":[],
2371            "version":1
2372        }))
2373        .unwrap();
2374        let action = CacheDigest::blake3(&action_bytes);
2375        let metadata = CacheDigest::blake3(&metadata_bytes);
2376        let output_root = CacheDigest::blake3(&directory_bytes);
2377        let result = RemoteActionResult {
2378            action: action.clone(),
2379            metadata: Some(metadata.clone()),
2380            output_root: Some(output_root.clone()),
2381            version: 1,
2382        };
2383        let prediction = ActionPrediction {
2384            invocation: invocation.clone(),
2385            action: action.clone(),
2386            adapter: "rustc".into(),
2387            payload: "{}".into(),
2388        };
2389        let manifest_bytes = canonical_json(&TaskActionManifest {
2390            version: TASK_ACTION_MANIFEST_VERSION,
2391            task: task.clone(),
2392            predictions: vec![prediction.clone()],
2393        })
2394        .unwrap();
2395        let manifest_etag = blake3::hash(&manifest_bytes).to_hex().to_string();
2396        let (_, selector) = CacheAgent::task_manifest_selector(&task).unwrap();
2397
2398        let mut mocks = Vec::new();
2399        for (digest, bytes) in [
2400            (&action, action_bytes.as_slice()),
2401            (&metadata, metadata_bytes.as_slice()),
2402            (&output_root, directory_bytes.as_slice()),
2403            (&stdout, stdout_bytes.as_slice()),
2404            (&stderr, stderr_bytes.as_slice()),
2405            (&artifact, artifact_bytes.as_slice()),
2406        ] {
2407            mocks.push(
2408                server
2409                    .mock("PUT", blob_path(digest).as_str())
2410                    .match_header("mbx-cache-namespace", "test")
2411                    .match_body(bytes.to_vec())
2412                    .with_status(200)
2413                    .expect(1)
2414                    .create_async()
2415                    .await,
2416            );
2417        }
2418        mocks.push(
2419            server
2420                .mock("PUT", action_path(&result.action).as_str())
2421                .match_header("mbx-cache-namespace", "test")
2422                .with_status(200)
2423                .expect(1)
2424                .create_async()
2425                .await,
2426        );
2427        mocks.push(
2428            server
2429                .mock("PUT", action_manifest_path(&selector).as_str())
2430                .match_header("mbx-cache-namespace", "test")
2431                .match_header("if-none-match", "*")
2432                .match_body(manifest_bytes.clone())
2433                .with_status(201)
2434                .expect(1)
2435                .create_async()
2436                .await,
2437        );
2438        mocks.push(
2439            server
2440                .mock("GET", action_manifest_path(&selector).as_str())
2441                .with_status(200)
2442                .with_header("etag", &format!("\"{manifest_etag}\""))
2443                .with_body(manifest_bytes.clone())
2444                .expect(1)
2445                .create_async()
2446                .await,
2447        );
2448        mocks.push(
2449            server
2450                .mock("GET", action_path(&action).as_str())
2451                .with_status(200)
2452                .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
2453                .with_body(serde_json::to_vec(&result).unwrap())
2454                .expect(1)
2455                .create_async()
2456                .await,
2457        );
2458        for (digest, bytes) in [
2459            (&action, action_bytes.as_slice()),
2460            (&metadata, metadata_bytes.as_slice()),
2461            (&output_root, directory_bytes.as_slice()),
2462            (&stdout, stdout_bytes.as_slice()),
2463            (&stderr, stderr_bytes.as_slice()),
2464            (&artifact, artifact_bytes.as_slice()),
2465        ] {
2466            mocks.push(
2467                server
2468                    .mock("GET", blob_path(digest).as_str())
2469                    .with_status(200)
2470                    .with_body(bytes)
2471                    .expect(1)
2472                    .create_async()
2473                    .await,
2474            );
2475        }
2476
2477        let writer = remote_agent(
2478            &server,
2479            directory.path().join("writer"),
2480            RemoteCacheMode::WriteOnly,
2481        );
2482        for (index, (digest, bytes)) in [
2483            (&action, action_bytes.as_slice()),
2484            (&metadata, metadata_bytes.as_slice()),
2485            (&output_root, directory_bytes.as_slice()),
2486            (&stdout, stdout_bytes.as_slice()),
2487            (&stderr, stderr_bytes.as_slice()),
2488            (&artifact, artifact_bytes.as_slice()),
2489        ]
2490        .into_iter()
2491        .enumerate()
2492        {
2493            let source = directory.path().join(format!("source-{index}"));
2494            fs::write(&source, bytes).unwrap();
2495            assert!(matches!(
2496                writer
2497                    .respond(AgentRequest::StoreBlob {
2498                        digest: digest.clone(),
2499                        source,
2500                    })
2501                    .await,
2502                AgentResponse::Stored { .. }
2503            ));
2504        }
2505        assert!(matches!(
2506            writer
2507                .respond(AgentRequest::StoreActionResult {
2508                    result: result.clone(),
2509                })
2510                .await,
2511            AgentResponse::ActionStored { .. }
2512        ));
2513        let run = writer.begin_task(&task).await.unwrap();
2514        assert!(matches!(
2515            writer
2516                .respond(AgentRequest::RecordActionPrediction {
2517                    task: run.clone(),
2518                    prediction: prediction.clone(),
2519                })
2520                .await,
2521            AgentResponse::ActionPredictionRecorded
2522        ));
2523        writer.commit_task(&run).await.unwrap();
2524
2525        let reader = remote_agent(
2526            &server,
2527            directory.path().join("reader"),
2528            RemoteCacheMode::ReadOnly,
2529        );
2530        let run = reader.begin_task(&task).await.unwrap();
2531        reader.wait_for_prefetches().await;
2532        assert!(matches!(
2533            reader
2534                .respond(AgentRequest::FindActionPrediction {
2535                    task: run,
2536                    invocation,
2537                })
2538                .await,
2539            AgentResponse::ActionPrediction {
2540                prediction: Some(found)
2541            } if found == prediction
2542        ));
2543        assert!(matches!(
2544            reader
2545                .respond(AgentRequest::FindActionResult {
2546                    action: action.clone(),
2547                })
2548                .await,
2549            AgentResponse::ActionResult {
2550                result: Some(found)
2551            } if found == result
2552        ));
2553        for digest in [&action, &metadata, &output_root] {
2554            assert!(matches!(
2555                reader
2556                    .respond(AgentRequest::FindBlob {
2557                        digest: digest.clone(),
2558                    })
2559                    .await,
2560                AgentResponse::Blob { path: Some(_) }
2561            ));
2562        }
2563        assert!(matches!(
2564            reader
2565                .respond(AgentRequest::RecordActionHit {
2566                    action,
2567                    restore: RestoreStats::default(),
2568                })
2569                .await,
2570            AgentResponse::ActionHitRecorded
2571        ));
2572        for mock in mocks {
2573            mock.assert_async().await;
2574        }
2575        let stats = reader.stats();
2576        assert_eq!(stats.prefetch_runs, 1);
2577        assert_eq!(stats.prefetched_actions, 1);
2578        assert!(stats.remote_manifest_lookups > 0);
2579        assert!(stats.remote_action_lookups > 0);
2580        assert!(stats.remote_blob_requests > 0);
2581        assert!(stats.remote_manifest_lookup_duration_ns > 0);
2582        assert!(stats.remote_action_lookup_duration_ns > 0);
2583        assert!(stats.remote_blob_transfer_duration_ns > 0);
2584        assert!(stats.local_cas_write_duration_ns > 0);
2585        assert!(stats.prefetch_duration_ns > 0);
2586    }
2587
2588    #[tokio::test]
2589    async fn keeps_newer_local_predictions_when_remote_manifest_is_stale() {
2590        let directory = tempfile::tempdir().unwrap();
2591        let mut server = mockito::Server::new_async().await;
2592        let task = "f".repeat(64);
2593        let invocation = CacheDigest::blake3(b"shared invocation");
2594        let local_prediction = ActionPrediction {
2595            invocation: invocation.clone(),
2596            action: CacheDigest::blake3(b"new local action"),
2597            adapter: "rustc".into(),
2598            payload: "{}".into(),
2599        };
2600        let remote_prediction = ActionPrediction {
2601            invocation: invocation.clone(),
2602            action: CacheDigest::blake3(b"stale remote action"),
2603            adapter: "rustc".into(),
2604            payload: "{}".into(),
2605        };
2606        let remote_manifest = TaskActionManifest {
2607            version: TASK_ACTION_MANIFEST_VERSION,
2608            task: task.clone(),
2609            predictions: vec![remote_prediction],
2610        };
2611        let remote_bytes = canonical_json(&remote_manifest).unwrap();
2612        let remote_etag = blake3::hash(&remote_bytes).to_hex().to_string();
2613        let (_, selector) = CacheAgent::task_manifest_selector(&task).unwrap();
2614        let remote = server
2615            .mock("GET", action_manifest_path(&selector).as_str())
2616            .with_status(200)
2617            .with_header("etag", &format!("\"{remote_etag}\""))
2618            .with_body(remote_bytes)
2619            .expect(1)
2620            .create_async()
2621            .await;
2622
2623        let agent = remote_agent(
2624            &server,
2625            directory.path().join("reader"),
2626            RemoteCacheMode::ReadOnly,
2627        );
2628        agent
2629            .persist_task_manifest(&TaskActionManifest {
2630                version: TASK_ACTION_MANIFEST_VERSION,
2631                task: task.clone(),
2632                predictions: vec![local_prediction.clone()],
2633            })
2634            .unwrap();
2635
2636        let run = agent.begin_task(&task).await.unwrap();
2637        assert!(matches!(
2638            agent
2639                .respond(AgentRequest::FindActionPrediction {
2640                    task: run,
2641                    invocation,
2642                })
2643                .await,
2644            AgentResponse::ActionPrediction {
2645                prediction: Some(found)
2646            } if found == local_prediction
2647        ));
2648        let persisted = agent.load_task_manifest(&task).unwrap().unwrap();
2649        assert_eq!(persisted.predictions, vec![local_prediction]);
2650        remote.assert_async().await;
2651    }
2652
2653    #[tokio::test]
2654    async fn prefetch_does_not_block_task_initialization() {
2655        let directory = tempfile::tempdir().unwrap();
2656        let mut server = mockito::Server::new_async().await;
2657        let task = "9".repeat(64);
2658        let invocation = CacheDigest::blake3(b"prefetched invocation");
2659        let action_bytes = b"prefetched action";
2660        let action = CacheDigest::blake3(action_bytes);
2661        let result = RemoteActionResult {
2662            action: action.clone(),
2663            metadata: None,
2664            output_root: None,
2665            version: 1,
2666        };
2667        let manifest_bytes = canonical_json(&TaskActionManifest {
2668            version: TASK_ACTION_MANIFEST_VERSION,
2669            task: task.clone(),
2670            predictions: vec![ActionPrediction {
2671                invocation,
2672                action: action.clone(),
2673                adapter: "rustc".into(),
2674                payload: "{}".into(),
2675            }],
2676        })
2677        .unwrap();
2678        let manifest_etag = blake3::hash(&manifest_bytes).to_hex().to_string();
2679        let (_, selector) = CacheAgent::task_manifest_selector(&task).unwrap();
2680        let manifest = server
2681            .mock("GET", action_manifest_path(&selector).as_str())
2682            .with_status(200)
2683            .with_header("etag", &format!("\"{manifest_etag}\""))
2684            .with_body(manifest_bytes)
2685            .expect(1)
2686            .create_async()
2687            .await;
2688        let release = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
2689        let response_release = release.clone();
2690        let result_bytes = serde_json::to_vec(&result).unwrap();
2691        let action_result = server
2692            .mock("GET", action_path(&action).as_str())
2693            .with_status(200)
2694            .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
2695            .with_chunked_body(move |writer| {
2696                let (released, condition) = &*response_release;
2697                let mut released = released.lock().unwrap();
2698                while !*released {
2699                    released = condition.wait(released).unwrap();
2700                }
2701                std::io::Write::write_all(writer, &result_bytes)
2702            })
2703            .expect(1)
2704            .create_async()
2705            .await;
2706        let action_blob = server
2707            .mock("GET", blob_path(&action).as_str())
2708            .with_status(200)
2709            .with_body(action_bytes)
2710            .expect(1)
2711            .create_async()
2712            .await;
2713        let agent = remote_agent(
2714            &server,
2715            directory.path().join("reader"),
2716            RemoteCacheMode::ReadOnly,
2717        );
2718
2719        let begin = tokio::time::timeout(Duration::from_secs(2), agent.begin_task(&task)).await;
2720        let (released, condition) = &*release;
2721        *released.lock().unwrap() = true;
2722        condition.notify_all();
2723        let run = begin
2724            .expect("task initialization waited for prefetch")
2725            .unwrap();
2726        assert_eq!(
2727            agent
2728                .task_actions
2729                .lock()
2730                .unwrap()
2731                .get(&run)
2732                .unwrap()
2733                .predictions
2734                .len(),
2735            1
2736        );
2737        agent.wait_for_prefetches().await;
2738        manifest.assert_async().await;
2739        action_result.assert_async().await;
2740        action_blob.assert_async().await;
2741        assert!(agent.actions.find(&action).unwrap().is_some());
2742    }
2743
2744    #[tokio::test]
2745    async fn prefetches_complete_actions_in_directory_wave_blob_packs() {
2746        let directory = tempfile::tempdir().unwrap();
2747        let mut server = mockito::Server::new_async().await;
2748        let action_bytes = b"packed action descriptor";
2749        let stdout_bytes = b"packed stdout";
2750        let stderr_bytes = b"packed stderr";
2751        let artifact_bytes = b"packed artifact";
2752        let action = CacheDigest::blake3(action_bytes);
2753        let stdout = CacheDigest::blake3(stdout_bytes);
2754        let stderr = CacheDigest::blake3(stderr_bytes);
2755        let artifact = CacheDigest::blake3(artifact_bytes);
2756        let metadata_bytes = canonical_json(&RustcMetadata {
2757            version: 1,
2758            kind: "rustc".into(),
2759            stdout: stdout.clone(),
2760            stderr: stderr.clone(),
2761        })
2762        .unwrap();
2763        let metadata = CacheDigest::blake3(&metadata_bytes);
2764        let directory_bytes = canonical_json(&serde_json::json!({
2765            "directories": [],
2766            "files": [{
2767                "digest": artifact,
2768                "executable": false,
2769                "mode": 420,
2770                "name": "artifact",
2771            }],
2772            "symlinks": [],
2773            "version": 1,
2774        }))
2775        .unwrap();
2776        let output_root = CacheDigest::blake3(&directory_bytes);
2777        let result = RemoteActionResult {
2778            action: action.clone(),
2779            metadata: Some(metadata.clone()),
2780            output_root: Some(output_root.clone()),
2781            version: 1,
2782        };
2783        let action_result = server
2784            .mock("GET", action_path(&action).as_str())
2785            .with_status(200)
2786            .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
2787            .with_body(serde_json::to_vec(&result).unwrap())
2788            .expect(1)
2789            .create_async()
2790            .await;
2791        let capabilities = server
2792            .mock("GET", "/v1/capabilities")
2793            .with_status(200)
2794            .with_header("content-type", "application/json")
2795            .with_body(
2796                serde_json::json!({
2797                    "protocol":{"major":1},
2798                    "features":{"blob_packs":true},
2799                    "limits":{"max_batch_items":100,"max_pack_bytes":1048576}
2800                })
2801                .to_string(),
2802            )
2803            .expect(1)
2804            .create_async()
2805            .await;
2806        let mut top = vec![
2807            (action.clone(), action_bytes.as_slice()),
2808            (metadata.clone(), metadata_bytes.as_slice()),
2809            (output_root.clone(), directory_bytes.as_slice()),
2810        ];
2811        top.sort_by(|left, right| left.0.cmp(&right.0));
2812        let first_pack = server
2813            .mock("POST", "/v1/blobs:pack")
2814            .match_body(mockito::Matcher::Json(serde_json::json!({
2815                "digests": top.iter().map(|(digest, _)| digest).collect::<Vec<_>>()
2816            })))
2817            .with_status(200)
2818            .with_header("content-type", crate::BLOB_PACK_MEDIA_TYPE)
2819            .with_body(blob_pack_body(&top))
2820            .expect(1)
2821            .create_async()
2822            .await;
2823        let mut leaves = vec![
2824            (stdout.clone(), stdout_bytes.as_slice()),
2825            (stderr.clone(), stderr_bytes.as_slice()),
2826            (artifact.clone(), artifact_bytes.as_slice()),
2827        ];
2828        leaves.sort_by(|left, right| left.0.cmp(&right.0));
2829        let second_pack = server
2830            .mock("POST", "/v1/blobs:pack")
2831            .match_body(mockito::Matcher::Json(serde_json::json!({
2832                "digests": leaves.iter().map(|(digest, _)| digest).collect::<Vec<_>>()
2833            })))
2834            .with_status(200)
2835            .with_header("content-type", crate::BLOB_PACK_MEDIA_TYPE)
2836            .with_body(blob_pack_body(&leaves))
2837            .expect(1)
2838            .create_async()
2839            .await;
2840        let agent = remote_agent(
2841            &server,
2842            directory.path().join("reader"),
2843            RemoteCacheMode::ReadOnly,
2844        );
2845
2846        agent
2847            .prefetch_action(action.clone(), "rustc".into())
2848            .await
2849            .unwrap();
2850
2851        assert_eq!(agent.actions.find(&action).unwrap(), Some(result));
2852        let stats = agent.stats();
2853        assert_eq!(stats.prefetched_actions, 1);
2854        assert_eq!(stats.remote_blob_requests, 0);
2855        assert_eq!(stats.remote_blob_pack_requests, 2);
2856        assert_eq!(stats.remote_blob_pack_blobs, 6);
2857        action_result.assert_async().await;
2858        capabilities.assert_async().await;
2859        first_pack.assert_async().await;
2860        second_pack.assert_async().await;
2861    }
2862
2863    #[tokio::test]
2864    async fn foreground_blob_batches_use_blob_packs() {
2865        let directory = tempfile::tempdir().unwrap();
2866        let mut server = mockito::Server::new_async().await;
2867        let mut entries = [
2868            (CacheDigest::blake3(b"first"), b"first".as_slice()),
2869            (CacheDigest::blake3(b"second"), b"second".as_slice()),
2870        ];
2871        entries.sort_by(|left, right| left.0.cmp(&right.0));
2872        let requested = entries
2873            .iter()
2874            .map(|(digest, _)| digest.clone())
2875            .collect::<Vec<_>>();
2876        let response_requested = vec![
2877            entries[0].0.clone(),
2878            entries[1].0.clone(),
2879            entries[0].0.clone(),
2880        ];
2881        let capabilities = server
2882            .mock("GET", "/v1/capabilities")
2883            .with_status(200)
2884            .with_header("content-type", "application/json")
2885            .with_body(
2886                serde_json::json!({
2887                    "protocol":{"major":1},
2888                    "features":{"blob_packs":true},
2889                    "limits":{"max_batch_items":100,"max_pack_bytes":1048576}
2890                })
2891                .to_string(),
2892            )
2893            .expect(1)
2894            .create_async()
2895            .await;
2896        let pack = server
2897            .mock("POST", "/v1/blobs:pack")
2898            .match_body(mockito::Matcher::Json(serde_json::json!({
2899                "digests": requested.clone()
2900            })))
2901            .with_status(200)
2902            .with_header("content-type", crate::BLOB_PACK_MEDIA_TYPE)
2903            .with_body(blob_pack_body(&entries))
2904            .expect(1)
2905            .create_async()
2906            .await;
2907        let agent = remote_agent(
2908            &server,
2909            directory.path().join("reader"),
2910            RemoteCacheMode::ReadOnly,
2911        );
2912        let response = agent
2913            .respond(AgentRequest::FindBlobs {
2914                digests: response_requested,
2915            })
2916            .await;
2917
2918        let AgentResponse::Blobs { paths } = response else {
2919            panic!("unexpected blob lookup response");
2920        };
2921        assert_eq!(paths.len(), 3);
2922        for (expected, path) in [entries[0].1, entries[1].1, entries[0].1]
2923            .into_iter()
2924            .zip(paths)
2925        {
2926            assert_eq!(fs::read(path.unwrap()).unwrap(), expected);
2927        }
2928        let stats = agent.stats();
2929        assert_eq!(stats.remote_blob_requests, 0);
2930        assert_eq!(stats.remote_blob_pack_requests, 1);
2931        assert_eq!(stats.remote_blob_pack_blobs, 2);
2932        capabilities.assert_async().await;
2933        pack.assert_async().await;
2934    }
2935
2936    #[tokio::test]
2937    async fn preserves_successful_pack_metrics_when_a_later_chunk_falls_back() {
2938        let directory = tempfile::tempdir().unwrap();
2939        let mut server = mockito::Server::new_async().await;
2940        let mut entries = [
2941            (CacheDigest::blake3(b"first"), b"first".as_slice()),
2942            (CacheDigest::blake3(b"second"), b"second".as_slice()),
2943        ];
2944        entries.sort_by(|left, right| left.0.cmp(&right.0));
2945        let (first_digest, first_bytes) = entries[0].clone();
2946        let (second_digest, second_bytes) = entries[1].clone();
2947        let capabilities = server
2948            .mock("GET", "/v1/capabilities")
2949            .with_status(200)
2950            .with_header("content-type", "application/json")
2951            .with_body(
2952                serde_json::json!({
2953                    "protocol":{"major":1},
2954                    "features":{"blob_packs":true},
2955                    "limits":{"max_batch_items":1,"max_pack_bytes":1048576}
2956                })
2957                .to_string(),
2958            )
2959            .expect(1)
2960            .create_async()
2961            .await;
2962        let first_pack = server
2963            .mock("POST", "/v1/blobs:pack")
2964            .match_body(mockito::Matcher::Json(serde_json::json!({
2965                "digests": [&first_digest]
2966            })))
2967            .with_status(200)
2968            .with_header("content-type", crate::BLOB_PACK_MEDIA_TYPE)
2969            .with_body(blob_pack_body(&[(first_digest.clone(), first_bytes)]))
2970            .expect(1)
2971            .create_async()
2972            .await;
2973        let failed_pack = server
2974            .mock("POST", "/v1/blobs:pack")
2975            .match_body(mockito::Matcher::Json(serde_json::json!({
2976                "digests": [&second_digest]
2977            })))
2978            .with_status(500)
2979            .expect(1)
2980            .create_async()
2981            .await;
2982        let fallback = server
2983            .mock("GET", blob_path(&second_digest).as_str())
2984            .with_status(200)
2985            .with_body(second_bytes)
2986            .expect(1)
2987            .create_async()
2988            .await;
2989        let agent = remote_agent(
2990            &server,
2991            directory.path().join("reader"),
2992            RemoteCacheMode::ReadOnly,
2993        );
2994        let remote = agent.remote.as_deref().unwrap();
2995
2996        let verified = agent
2997            .fetch_remote_blobs(
2998                remote,
2999                vec![first_digest.clone(), second_digest.clone()],
3000                Some(&agent.prefetch_transfers),
3001            )
3002            .await;
3003
3004        assert_eq!(verified.len(), 2);
3005        assert_eq!(fs::read(&verified[&first_digest]).unwrap(), first_bytes);
3006        assert_eq!(fs::read(&verified[&second_digest]).unwrap(), second_bytes);
3007        let stats = agent.stats();
3008        assert_eq!(stats.remote_blob_pack_requests, 1);
3009        assert_eq!(stats.remote_blob_pack_blobs, 1);
3010        assert_eq!(stats.remote_blob_requests, 1);
3011        capabilities.assert_async().await;
3012        first_pack.assert_async().await;
3013        failed_pack.assert_async().await;
3014        fallback.assert_async().await;
3015    }
3016
3017    #[tokio::test]
3018    async fn malformed_blob_pack_metadata_falls_back_to_individual_blobs() {
3019        let directory = tempfile::tempdir().unwrap();
3020        let mut server = mockito::Server::new_async().await;
3021        let bytes = b"fallback blob";
3022        let digest = CacheDigest::blake3(bytes);
3023        server
3024            .mock("GET", "/v1/capabilities")
3025            .with_status(200)
3026            .with_header("content-type", "application/json")
3027            .with_body(
3028                serde_json::json!({
3029                    "protocol":{"major":1},
3030                    "features":{"blob_packs":true},
3031                    "limits":{"max_batch_items":100,"max_pack_bytes":1048576}
3032                })
3033                .to_string(),
3034            )
3035            .expect(1)
3036            .create_async()
3037            .await;
3038        let pack = server
3039            .mock("POST", "/v1/blobs:pack")
3040            .with_status(200)
3041            .with_header("content-type", crate::BLOB_PACK_MEDIA_TYPE)
3042            .with_header(crate::BLOB_PACK_BYTES_HEADER, "not-a-number")
3043            .with_body(blob_pack_body(&[(digest.clone(), bytes.as_slice())]))
3044            .expect(1)
3045            .create_async()
3046            .await;
3047        let fallback = server
3048            .mock("GET", blob_path(&digest).as_str())
3049            .with_status(200)
3050            .with_body(bytes)
3051            .expect(1)
3052            .create_async()
3053            .await;
3054        let agent = remote_agent(
3055            &server,
3056            directory.path().join("reader"),
3057            RemoteCacheMode::ReadOnly,
3058        );
3059        let remote = agent.remote.as_deref().unwrap();
3060
3061        let verified = agent
3062            .fetch_remote_blobs(remote, vec![digest.clone()], None)
3063            .await;
3064
3065        assert_eq!(fs::read(&verified[&digest]).unwrap(), bytes);
3066        let stats = agent.stats();
3067        assert_eq!(stats.remote_blob_pack_requests, 0);
3068        assert_eq!(stats.remote_blob_pack_blobs, 0);
3069        assert_eq!(stats.remote_blob_requests, 1);
3070        pack.assert_async().await;
3071        fallback.assert_async().await;
3072    }
3073
3074    #[tokio::test]
3075    async fn foreground_action_lookup_does_not_wait_for_prefetch_output() {
3076        let directory = tempfile::tempdir().unwrap();
3077        let mut server = mockito::Server::new_async().await;
3078        let action_bytes = b"prefetched action";
3079        let artifact_bytes = b"prefetched artifact";
3080        let action = CacheDigest::blake3(action_bytes);
3081        let artifact = CacheDigest::blake3(artifact_bytes);
3082        let directory_bytes = canonical_json(&serde_json::json!({
3083            "directories": [],
3084            "files": [{
3085                "digest": artifact,
3086                "executable": false,
3087                "mode": 420,
3088                "name": "artifact",
3089            }],
3090            "symlinks": [],
3091            "version": 1,
3092        }))
3093        .unwrap();
3094        let output_root = CacheDigest::blake3(&directory_bytes);
3095        let result = RemoteActionResult {
3096            action: action.clone(),
3097            metadata: None,
3098            output_root: Some(output_root.clone()),
3099            version: 1,
3100        };
3101        let action_result = server
3102            .mock("GET", action_path(&action).as_str())
3103            .with_status(200)
3104            .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
3105            .with_body(serde_json::to_vec(&result).unwrap())
3106            .expect(1)
3107            .create_async()
3108            .await;
3109        let action_blob = server
3110            .mock("GET", blob_path(&action).as_str())
3111            .with_status(200)
3112            .with_body(action_bytes)
3113            .expect(1)
3114            .create_async()
3115            .await;
3116        let output_directory = server
3117            .mock("GET", blob_path(&output_root).as_str())
3118            .with_status(200)
3119            .with_body(directory_bytes)
3120            .expect(1)
3121            .create_async()
3122            .await;
3123        let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
3124        let release = Arc::new(std::sync::atomic::AtomicBool::new(false));
3125        let response_started = started.clone();
3126        let response_release = release.clone();
3127        let artifact_blob = server
3128            .mock("GET", blob_path(&artifact).as_str())
3129            .with_status(200)
3130            .with_chunked_body(move |writer| {
3131                response_started.store(true, Ordering::Release);
3132                while !response_release.load(Ordering::Acquire) {
3133                    std::thread::sleep(Duration::from_millis(10));
3134                }
3135                std::io::Write::write_all(writer, artifact_bytes)
3136            })
3137            .expect(1)
3138            .create_async()
3139            .await;
3140        let agent = remote_agent(
3141            &server,
3142            directory.path().join("reader"),
3143            RemoteCacheMode::ReadOnly,
3144        );
3145        let prefetch_agent = agent.clone();
3146        let prefetch_action = action.clone();
3147        let prefetch = tokio::spawn(async move {
3148            prefetch_agent
3149                .prefetch_action(prefetch_action, "rustc".into())
3150                .await
3151        });
3152        tokio::time::timeout(Duration::from_secs(1), async {
3153            while !started.load(Ordering::Acquire) {
3154                tokio::time::sleep(Duration::from_millis(10)).await;
3155            }
3156        })
3157        .await
3158        .expect("prefetch did not request the output blob");
3159
3160        let foreground = tokio::time::timeout(
3161            Duration::from_millis(250),
3162            agent.find_action_result(&action),
3163        )
3164        .await;
3165        release.store(true, Ordering::Release);
3166        prefetch.await.unwrap().unwrap();
3167        let foreground = foreground.expect("foreground action lookup waited for output prefetch");
3168
3169        assert!(matches!(
3170            foreground.unwrap(),
3171            AgentResponse::ActionResult {
3172                result: Some(found)
3173            } if found == result
3174        ));
3175        action_result.assert_async().await;
3176        action_blob.assert_async().await;
3177        output_directory.assert_async().await;
3178        artifact_blob.assert_async().await;
3179    }
3180
3181    #[tokio::test]
3182    async fn session_completion_cancels_outstanding_prefetches() {
3183        let directory = tempfile::tempdir().unwrap();
3184        let agent = CacheAgent::new(directory.path(), "test-version");
3185        let task = tokio::spawn(std::future::pending::<()>());
3186        agent.prefetch_tasks.lock().unwrap().push(task);
3187
3188        tokio::time::timeout(Duration::from_secs(1), agent.cancel_prefetches())
3189            .await
3190            .expect("prefetch cancellation blocked session completion");
3191        assert!(agent.prefetch_tasks.lock().unwrap().is_empty());
3192    }
3193
3194    #[tokio::test]
3195    async fn prefetch_reserves_capacity_for_foreground_transfers() {
3196        let transfers = tokio::sync::Semaphore::new(MAX_REMOTE_TRANSFERS);
3197        let _prefetch = transfers
3198            .acquire_many(MAX_PREFETCH_TRANSFERS as u32)
3199            .await
3200            .unwrap();
3201        assert!(transfers.available_permits() > 0);
3202    }
3203
3204    #[tokio::test]
3205    async fn prefetches_output_files_concurrently() {
3206        let directory = tempfile::tempdir().unwrap();
3207        let (responses, output_root) = output_tree_responses(8);
3208        let (base_url, maximum_in_flight, server) =
3209            delayed_blob_server(responses, Duration::from_millis(50)).await;
3210        let agent = remote_agent_url(
3211            base_url,
3212            directory.path().join("reader"),
3213            RemoteCacheMode::ReadOnly,
3214        );
3215
3216        agent
3217            .prefetch_output_tree(agent.remote.as_deref().unwrap(), &output_root)
3218            .await
3219            .unwrap();
3220        server.await.unwrap();
3221
3222        assert!(maximum_in_flight.load(Ordering::Relaxed) > 1);
3223    }
3224
3225    #[tokio::test]
3226    #[ignore = "local remote-cache throughput benchmark"]
3227    async fn benchmark_prefetch_output_tree_latency() {
3228        let files = std::env::var("MBX_BENCH_FILES")
3229            .ok()
3230            .and_then(|value| value.parse().ok())
3231            .unwrap_or(96);
3232        let latency = Duration::from_millis(
3233            std::env::var("MBX_BENCH_LATENCY_MS")
3234                .ok()
3235                .and_then(|value| value.parse().ok())
3236                .unwrap_or(100),
3237        );
3238
3239        let directory = tempfile::tempdir().unwrap();
3240        let (responses, output_root) = output_tree_responses(files);
3241        let (base_url, maximum_in_flight, server) = delayed_blob_server(responses, latency).await;
3242        let agent = remote_agent_url(
3243            base_url,
3244            directory.path().join("reader"),
3245            RemoteCacheMode::ReadOnly,
3246        );
3247        let remote = agent.remote.as_deref().unwrap();
3248
3249        let started = std::time::Instant::now();
3250        agent
3251            .prefetch_output_tree(remote, &output_root)
3252            .await
3253            .unwrap();
3254        let elapsed = started.elapsed();
3255
3256        eprintln!(
3257            "prefetched {files} blobs with {} ms latency in {elapsed:?}",
3258            latency.as_millis()
3259        );
3260        server.await.unwrap();
3261        eprintln!(
3262            "maximum concurrent requests: {}",
3263            maximum_in_flight.load(Ordering::Relaxed)
3264        );
3265    }
3266
3267    fn output_tree_responses(files: usize) -> (BTreeMap<String, Vec<u8>>, CacheDigest) {
3268        let mut entries = Vec::with_capacity(files);
3269        let mut responses = BTreeMap::new();
3270        for index in 0..files {
3271            let body = format!("cached artifact {index}").into_bytes();
3272            let digest = CacheDigest::blake3(&body);
3273            entries.push(serde_json::json!({
3274                "digest": digest,
3275                "executable": false,
3276                "mode": 420,
3277                "name": format!("artifact-{index}"),
3278            }));
3279            responses.insert(blob_path(&digest), body);
3280        }
3281        let directory = canonical_json(&serde_json::json!({
3282            "directories": [],
3283            "files": entries,
3284            "symlinks": [],
3285            "version": 1,
3286        }))
3287        .unwrap();
3288        let output_root = CacheDigest::blake3(&directory);
3289        responses.insert(blob_path(&output_root), directory);
3290        (responses, output_root)
3291    }
3292
3293    fn blob_pack_body(entries: &[(CacheDigest, &[u8])]) -> Vec<u8> {
3294        let mut pack = crate::BLOB_PACK_MAGIC.to_vec();
3295        for (digest, bytes) in entries {
3296            pack.push(match digest.algorithm.as_str() {
3297                "blake3" => 1,
3298                "sha256" => 2,
3299                algorithm => panic!("unexpected test digest algorithm {algorithm}"),
3300            });
3301            pack.extend(hex::decode(&digest.hash).unwrap());
3302            pack.extend(digest.size.to_be_bytes());
3303            pack.extend_from_slice(bytes);
3304        }
3305        pack
3306    }
3307
3308    async fn delayed_blob_server(
3309        responses: BTreeMap<String, Vec<u8>>,
3310        latency: Duration,
3311    ) -> (
3312        url::Url,
3313        Arc<std::sync::atomic::AtomicUsize>,
3314        tokio::task::JoinHandle<()>,
3315    ) {
3316        use std::sync::atomic::AtomicUsize;
3317        use tokio::io::{AsyncReadExt, AsyncWriteExt};
3318
3319        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3320        let address = listener.local_addr().unwrap();
3321        let responses = Arc::new(responses);
3322        let request_count = responses.len();
3323        let in_flight = Arc::new(AtomicUsize::new(0));
3324        let maximum_in_flight = Arc::new(AtomicUsize::new(0));
3325        let observed_maximum = maximum_in_flight.clone();
3326        let server = tokio::spawn(async move {
3327            let mut requests = tokio::task::JoinSet::new();
3328            for _ in 0..request_count {
3329                let (mut socket, _) = listener.accept().await.unwrap();
3330                let responses = responses.clone();
3331                let in_flight = in_flight.clone();
3332                let maximum_in_flight = maximum_in_flight.clone();
3333                requests.spawn(async move {
3334                    let mut request = Vec::new();
3335                    loop {
3336                        let mut chunk = [0; 1024];
3337                        let size = socket.read(&mut chunk).await.unwrap();
3338                        assert!(size > 0, "client closed before sending request headers");
3339                        request.extend_from_slice(&chunk[..size]);
3340                        if request.windows(4).any(|window| window == b"\r\n\r\n") {
3341                            break;
3342                        }
3343                    }
3344                    let request = String::from_utf8_lossy(&request);
3345                    let path = request
3346                        .lines()
3347                        .next()
3348                        .and_then(|line| line.split_whitespace().nth(1))
3349                        .unwrap();
3350                    let body = responses.get(path).unwrap();
3351                    let active = in_flight.fetch_add(1, Ordering::Relaxed) + 1;
3352                    maximum_in_flight.fetch_max(active, Ordering::Relaxed);
3353                    tokio::time::sleep(latency).await;
3354                    socket
3355                        .write_all(
3356                            format!(
3357                                "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
3358                                body.len()
3359                            )
3360                            .as_bytes(),
3361                        )
3362                        .await
3363                        .unwrap();
3364                    socket.write_all(body).await.unwrap();
3365                    in_flight.fetch_sub(1, Ordering::Relaxed);
3366                });
3367            }
3368            while requests.join_next().await.is_some() {}
3369        });
3370        (
3371            format!("http://{address}").parse().unwrap(),
3372            observed_maximum,
3373            server,
3374        )
3375    }
3376
3377    fn remote_agent(
3378        server: &mockito::ServerGuard,
3379        cache_dir: PathBuf,
3380        mode: RemoteCacheMode,
3381    ) -> CacheAgent {
3382        remote_agent_url(server.url().parse().unwrap(), cache_dir, mode)
3383    }
3384
3385    fn remote_agent_url(
3386        base_url: url::Url,
3387        cache_dir: PathBuf,
3388        mode: RemoteCacheMode,
3389    ) -> CacheAgent {
3390        let client = RemoteCacheClient::new(crate::RemoteCacheConfig {
3391            base_url,
3392            namespace: "test".into(),
3393            token: None,
3394            token_file: None,
3395            oidc_audience: None,
3396            connect_timeout: Duration::from_secs(1),
3397            read_timeout: Duration::from_secs(1),
3398            download_timeout: Duration::from_secs(1),
3399            retries: 0,
3400        })
3401        .unwrap();
3402        CacheAgent::new_remote(
3403            &cache_dir,
3404            "test-version",
3405            AgentRemoteCache {
3406                client,
3407                mode,
3408                staging_dir: cache_dir.join("remote"),
3409            },
3410        )
3411    }
3412
3413    fn blob_path(digest: &CacheDigest) -> String {
3414        format!(
3415            "/v1/blobs/{}/{}/{}",
3416            digest.algorithm, digest.hash, digest.size
3417        )
3418    }
3419
3420    fn action_path(digest: &CacheDigest) -> String {
3421        format!(
3422            "/v1/action-results/{}/{}/{}",
3423            digest.algorithm, digest.hash, digest.size
3424        )
3425    }
3426
3427    fn action_manifest_path(digest: &CacheDigest) -> String {
3428        format!(
3429            "/v1/action-manifests/{}/{}/{}",
3430            digest.algorithm, digest.hash, digest.size
3431        )
3432    }
3433
3434    #[tokio::test]
3435    async fn merges_overlapping_runs_into_one_task_manifest() {
3436        let directory = tempfile::tempdir().unwrap();
3437        let cache = directory.path().join("cache");
3438        let task = "d".repeat(64);
3439        let agent = CacheAgent::new(&cache, "test-version");
3440        let first_run = agent.begin_task(&task).await.unwrap();
3441        let second_run = agent.begin_task(&task).await.unwrap();
3442        assert_ne!(first_run, second_run);
3443        let first_invocation = CacheDigest::blake3(b"overlap one");
3444        let second_invocation = CacheDigest::blake3(b"overlap two");
3445        for (run, invocation) in [
3446            (&first_run, &first_invocation),
3447            (&second_run, &second_invocation),
3448        ] {
3449            assert!(matches!(
3450                agent
3451                    .respond(AgentRequest::RecordActionPrediction {
3452                        task: run.clone(),
3453                        prediction: ActionPrediction {
3454                            invocation: invocation.clone(),
3455                            action: CacheDigest::blake3(invocation.hash.as_bytes()),
3456                            adapter: "rustc".into(),
3457                            payload: "{}".into(),
3458                        },
3459                    })
3460                    .await,
3461                AgentResponse::ActionPredictionRecorded
3462            ));
3463        }
3464        agent.commit_task(&first_run).await.unwrap();
3465        agent.commit_task(&second_run).await.unwrap();
3466
3467        let next = CacheAgent::new(cache, "test-version");
3468        let run = next.begin_task(&task).await.unwrap();
3469        for invocation in [first_invocation, second_invocation] {
3470            assert!(matches!(
3471                next.respond(AgentRequest::FindActionPrediction {
3472                    task: run.clone(),
3473                    invocation,
3474                })
3475                .await,
3476                AgentResponse::ActionPrediction {
3477                    prediction: Some(_)
3478                }
3479            ));
3480        }
3481    }
3482
3483    #[test]
3484    fn keeps_local_manifest_when_remote_merge_exceeds_prediction_limit() {
3485        let task = "7".repeat(64);
3486        let prediction = |index: usize| {
3487            let digest = CacheDigest::blake3(&index.to_le_bytes());
3488            ActionPrediction {
3489                invocation: digest.clone(),
3490                action: digest,
3491                adapter: "rustc".into(),
3492                payload: "{}".into(),
3493            }
3494        };
3495        let local = TaskActionManifest {
3496            version: TASK_ACTION_MANIFEST_VERSION,
3497            task: task.clone(),
3498            predictions: (0..MAX_TASK_ACTION_PREDICTIONS).map(prediction).collect(),
3499        };
3500        let expected_first = local.predictions[0].clone();
3501        let remote = TaskActionManifest {
3502            version: TASK_ACTION_MANIFEST_VERSION,
3503            task: task.clone(),
3504            predictions: vec![prediction(MAX_TASK_ACTION_PREDICTIONS)],
3505        };
3506
3507        let (manifest, merged) = merge_remote_task_manifest(&task, remote, local);
3508        assert!(!merged);
3509        assert_eq!(manifest.predictions.len(), MAX_TASK_ACTION_PREDICTIONS);
3510        assert_eq!(manifest.predictions[0], expected_first);
3511    }
3512
3513    #[test]
3514    fn task_manifest_lock_is_shared_across_agents() {
3515        let directory = tempfile::tempdir().unwrap();
3516        let cache = directory.path().join("cache");
3517        let first = CacheAgent::new(&cache, "test-version");
3518        let second = CacheAgent::new(&cache, "test-version");
3519        let task = "8".repeat(64);
3520
3521        let first_lock = first.lock_task_manifest(&task).unwrap();
3522        let mut contender = fslock::LockFile::open(&second.task_manifest_lock_path(&task)).unwrap();
3523        assert!(!contender.try_lock().unwrap());
3524        drop(first_lock);
3525        assert!(contender.try_lock().unwrap());
3526    }
3527
3528    #[tokio::test]
3529    async fn memoizes_client_observed_executable_identities() {
3530        let directory = tempfile::tempdir().unwrap();
3531        let agent = CacheAgent::new(directory.path(), "test-version");
3532        let executable = directory.path().join("rustc");
3533        let environment = BTreeMap::from([("RUSTUP_TOOLCHAIN".into(), Some("stable".into()))]);
3534
3535        let response = agent
3536            .respond(AgentRequest::FindExecutableIdentity {
3537                executable: executable.clone(),
3538                environment: environment.clone(),
3539            })
3540            .await;
3541        assert!(matches!(
3542            response,
3543            AgentResponse::ExecutableIdentity { stdout: None }
3544        ));
3545
3546        let response = agent
3547            .respond(AgentRequest::StoreExecutableIdentity {
3548                executable: executable.clone(),
3549                environment: environment.clone(),
3550                stdout: b"rustc identity".to_vec(),
3551            })
3552            .await;
3553        assert!(matches!(
3554            response,
3555            AgentResponse::ExecutableIdentity {
3556                stdout: Some(stdout)
3557            } if stdout == b"rustc identity"
3558        ));
3559
3560        let response = agent
3561            .respond(AgentRequest::FindExecutableIdentity {
3562                executable,
3563                environment,
3564            })
3565            .await;
3566        assert!(matches!(
3567            response,
3568            AgentResponse::ExecutableIdentity {
3569                stdout: Some(stdout)
3570            } if stdout == b"rustc identity"
3571        ));
3572    }
3573
3574    #[test]
3575    fn bounds_executable_identity_entry_count() {
3576        let directory = tempfile::tempdir().unwrap();
3577        let agent = CacheAgent::new(directory.path(), "test-version");
3578        for index in 0..MAX_EXECUTABLE_IDENTITIES {
3579            agent
3580                .store_executable_identity(
3581                    directory.path().join(format!("rustc-{index}")),
3582                    BTreeMap::new(),
3583                    vec![b'x'],
3584                )
3585                .unwrap();
3586        }
3587
3588        assert!(
3589            agent
3590                .store_executable_identity(
3591                    directory.path().join("one-too-many"),
3592                    BTreeMap::new(),
3593                    vec![b'x'],
3594                )
3595                .is_err()
3596        );
3597    }
3598
3599    #[test]
3600    fn bounds_executable_identity_retained_bytes() {
3601        let directory = tempfile::tempdir().unwrap();
3602        let agent = CacheAgent::new(directory.path(), "test-version");
3603        for index in 0..MAX_EXECUTABLE_IDENTITY_BYTES / MAX_EXECUTABLE_IDENTITY_SIZE {
3604            agent
3605                .store_executable_identity(
3606                    directory.path().join(format!("rustc-{index}")),
3607                    BTreeMap::new(),
3608                    vec![b'x'; MAX_EXECUTABLE_IDENTITY_SIZE],
3609                )
3610                .unwrap();
3611        }
3612
3613        assert!(
3614            agent
3615                .store_executable_identity(
3616                    directory.path().join("one-byte-too-many"),
3617                    BTreeMap::new(),
3618                    vec![b'x'],
3619                )
3620                .is_err()
3621        );
3622    }
3623
3624    #[tokio::test]
3625    async fn version_skew_is_a_handshake_miss() {
3626        let directory = tempfile::tempdir().unwrap();
3627        let agent = CacheAgent::new(directory.path(), "agent-version");
3628        let (mut client, server) = tokio::io::duplex(1024);
3629        let task = tokio::spawn(async move { agent.handle_connection(server).await });
3630        let request = AgentRequest::Hello {
3631            protocol: AGENT_PROTOCOL_VERSION,
3632            client_version: "other-version".into(),
3633        };
3634        let mut encoded = serde_json::to_vec(&request).unwrap();
3635        encoded.push(b'\n');
3636        client.write_all(&encoded).await.unwrap();
3637        let mut response = String::new();
3638        BufReader::new(&mut client)
3639            .read_line(&mut response)
3640            .await
3641            .unwrap();
3642
3643        assert!(matches!(
3644            serde_json::from_str(&response).unwrap(),
3645            AgentResponse::Error { .. }
3646        ));
3647        task.await.unwrap().unwrap();
3648    }
3649}