Skip to main content

mise_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::{StreamExt, 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 tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
16
17const MAX_EXECUTABLE_IDENTITIES: usize = 64;
18const MAX_EXECUTABLE_IDENTITY_SIZE: usize = 64 * 1024;
19const MAX_EXECUTABLE_IDENTITY_BYTES: usize = 256 * 1024;
20const TASK_ACTION_MANIFEST_VERSION: u8 = 1;
21const MAX_TASK_ACTION_PREDICTIONS: usize = 16 * 1024;
22const MAX_ACTION_PREDICTION_PAYLOAD: usize = 256 * 1024;
23const MAX_REMOTE_TRANSFERS: usize = 64;
24const MAX_PREFETCH_TRANSFERS: usize = 48;
25const MAX_PREFETCH_DIRECTORY_OBJECTS: usize = 100_000;
26
27/// Remote action-cache access owned by one task session.
28pub struct AgentRemoteCache {
29    pub client: RemoteCacheClient,
30    pub mode: RemoteCacheMode,
31    pub staging_dir: PathBuf,
32}
33
34/// Wire protocol version used between an in-process cache agent and its shims.
35pub const AGENT_PROTOCOL_VERSION: u8 = 1;
36
37/// A request accepted by the task-scoped cache agent.
38#[derive(Debug, Serialize, Deserialize)]
39#[serde(tag = "type", rename_all = "snake_case")]
40pub enum AgentRequest {
41    Hello {
42        protocol: u8,
43        client_version: String,
44    },
45    FindBlob {
46        digest: CacheDigest,
47    },
48    StoreBlob {
49        digest: CacheDigest,
50        source: PathBuf,
51    },
52    FindActionResult {
53        action: CacheDigest,
54    },
55    RecordActionHit {
56        action: CacheDigest,
57    },
58    RecordActionVerification {
59        matched: bool,
60    },
61    StoreActionResult {
62        result: RemoteActionResult,
63    },
64    FindActionPrediction {
65        task: String,
66        invocation: CacheDigest,
67    },
68    RecordActionPrediction {
69        task: String,
70        prediction: ActionPrediction,
71    },
72    FindExecutableIdentity {
73        executable: PathBuf,
74        environment: BTreeMap<String, Option<String>>,
75    },
76    StoreExecutableIdentity {
77        executable: PathBuf,
78        environment: BTreeMap<String, Option<String>>,
79        stdout: Vec<u8>,
80    },
81}
82
83/// A response returned by the task-scoped cache agent.
84#[derive(Debug, Serialize, Deserialize)]
85#[serde(tag = "type", rename_all = "snake_case")]
86pub enum AgentResponse {
87    Hello {
88        protocol: u8,
89        agent_version: String,
90    },
91    Blob {
92        path: Option<PathBuf>,
93    },
94    Stored {
95        path: PathBuf,
96    },
97    ActionResult {
98        result: Option<RemoteActionResult>,
99    },
100    ActionHitRecorded,
101    ActionVerificationRecorded,
102    ActionStored {
103        path: PathBuf,
104    },
105    ActionPrediction {
106        prediction: Option<ActionPrediction>,
107    },
108    ActionPredictionRecorded,
109    ExecutableIdentity {
110        stdout: Option<Vec<u8>>,
111    },
112    Error {
113        message: String,
114    },
115}
116
117/// Aggregate cache activity for one task session.
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
119pub struct AgentStats {
120    /// Number of action-result lookups.
121    pub lookups: u64,
122    /// Number of lookups that found a valid local action result.
123    pub hits: u64,
124    /// Number of newly stored content-addressed objects.
125    pub stores: u64,
126    /// Total size of newly stored objects.
127    pub stored_bytes: u64,
128    /// Number of cache hits compiled again for qualification.
129    pub verifications: u64,
130    /// Number of qualification builds that diverged from the cached result.
131    pub divergences: u64,
132    /// CAS payload bytes downloaded from the remote cache.
133    pub downloaded_bytes: u64,
134    /// CAS payload bytes uploaded to the remote cache.
135    pub uploaded_bytes: u64,
136    /// Complete actions staged before an adapter requested them.
137    pub prefetched_actions: u64,
138}
139
140/// Adapter-owned data needed to reconstruct an action before fresh dependency
141/// discovery is available.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(deny_unknown_fields)]
144pub struct ActionPrediction {
145    pub invocation: CacheDigest,
146    pub action: CacheDigest,
147    pub adapter: String,
148    pub payload: String,
149}
150
151#[derive(Default)]
152struct AtomicAgentStats {
153    lookups: AtomicU64,
154    hits: AtomicU64,
155    stores: AtomicU64,
156    stored_bytes: AtomicU64,
157    verifications: AtomicU64,
158    divergences: AtomicU64,
159    downloaded_bytes: AtomicU64,
160    uploaded_bytes: AtomicU64,
161    prefetched_actions: AtomicU64,
162}
163
164/// Shared state for an agent hosted by the top-level `mise run` process.
165///
166/// Transport listeners deliberately live in mise so the task-run lifecycle owns
167/// them. This type only contains ecosystem-independent CAS and protocol logic.
168#[derive(Clone)]
169pub struct CacheAgent {
170    cas: LocalCas,
171    actions: LocalActionCache,
172    version: Arc<str>,
173    write_locks: Arc<Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>>,
174    action_locks: Arc<Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>>,
175    stats: Arc<AtomicAgentStats>,
176    executable_identities: Arc<Mutex<BTreeMap<ExecutableIdentityKey, Vec<u8>>>>,
177    manifest_dir: Arc<PathBuf>,
178    task_actions: Arc<Mutex<BTreeMap<String, TaskActionState>>>,
179    next_task_run: Arc<AtomicU64>,
180    manifest_write_lock: Arc<Mutex<()>>,
181    remote: Option<Arc<RemoteCacheClient>>,
182    remote_mode: RemoteCacheMode,
183    remote_staging_dir: Arc<PathBuf>,
184    pending_remote_actions: Arc<Mutex<BTreeMap<CacheDigest, RemoteActionResult>>>,
185    remote_transfers: Arc<tokio::sync::Semaphore>,
186    prefetch_transfers: Arc<tokio::sync::Semaphore>,
187    prefetch_tasks: Arc<Mutex<Vec<tokio::task::JoinHandle<()>>>>,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191#[serde(deny_unknown_fields)]
192struct TaskActionManifest {
193    version: u8,
194    task: String,
195    predictions: Vec<ActionPrediction>,
196}
197
198#[derive(Serialize)]
199struct TaskActionManifestSelector<'a> {
200    version: u8,
201    kind: &'static str,
202    task: &'a str,
203}
204
205#[derive(Debug, Clone, Default)]
206struct TaskActionState {
207    manifest: String,
208    baseline_loaded: bool,
209    predictions: BTreeMap<CacheDigest, ActionPrediction>,
210    remote_etag: Option<String>,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
214struct ExecutableIdentityKey {
215    executable: PathBuf,
216    environment: BTreeMap<String, Option<String>>,
217}
218
219impl CacheAgent {
220    /// Create an agent backed by the cache rooted at `cache_dir`.
221    pub fn new(cache_dir: impl Into<PathBuf>, version: impl Into<Arc<str>>) -> Self {
222        Self::build(cache_dir.into(), version.into(), None)
223    }
224
225    /// Create an agent with local-first access to a remote action cache.
226    pub fn new_remote(
227        cache_dir: impl Into<PathBuf>,
228        version: impl Into<Arc<str>>,
229        remote: AgentRemoteCache,
230    ) -> Self {
231        Self::build(cache_dir.into(), version.into(), Some(remote))
232    }
233
234    fn build(cache_dir: PathBuf, version: Arc<str>, remote: Option<AgentRemoteCache>) -> Self {
235        let remote_mode = remote
236            .as_ref()
237            .map_or(RemoteCacheMode::ReadOnly, |remote| remote.mode);
238        let remote_staging_dir = remote.as_ref().map_or_else(
239            || cache_dir.join("remote"),
240            |remote| remote.staging_dir.clone(),
241        );
242        let remote = remote.map(|remote| Arc::new(remote.client));
243        Self {
244            cas: LocalCas::new(cache_dir.clone()),
245            actions: LocalActionCache::new(cache_dir.clone()),
246            version,
247            write_locks: Arc::new(Mutex::new(BTreeMap::new())),
248            action_locks: Arc::new(Mutex::new(BTreeMap::new())),
249            stats: Arc::new(AtomicAgentStats::default()),
250            executable_identities: Arc::new(Mutex::new(BTreeMap::new())),
251            manifest_dir: Arc::new(cache_dir.join("task-manifests").join("v1")),
252            task_actions: Arc::new(Mutex::new(BTreeMap::new())),
253            next_task_run: Arc::new(AtomicU64::new(0)),
254            manifest_write_lock: Arc::new(Mutex::new(())),
255            remote,
256            remote_mode,
257            remote_staging_dir: Arc::new(remote_staging_dir),
258            pending_remote_actions: Arc::new(Mutex::new(BTreeMap::new())),
259            remote_transfers: Arc::new(tokio::sync::Semaphore::new(MAX_REMOTE_TRANSFERS)),
260            prefetch_transfers: Arc::new(tokio::sync::Semaphore::new(MAX_PREFETCH_TRANSFERS)),
261            prefetch_tasks: Arc::new(Mutex::new(Vec::new())),
262        }
263    }
264
265    /// Load the last successful action manifest for a task into this session.
266    pub async fn begin_task(&self, task: &str) -> Result<String> {
267        validate_task_identity(task)?;
268        let (remote_manifest, mut remote_etag) = if self.remote_mode.reads() {
269            match self.get_remote_task_manifest(task).await {
270                Ok(Some((manifest, etag))) => (Some(manifest), Some(etag)),
271                Ok(None) => (None, None),
272                Err(error) => {
273                    warn!("remote task action manifest lookup failed for {task}: {error}");
274                    (None, None)
275                }
276            }
277        } else {
278            (None, None)
279        };
280        let manifest = {
281            let _write_guard = self.manifest_write_lock.lock().unwrap();
282            let _file_guard = self.lock_task_manifest(task)?;
283            let local_manifest = self.load_task_manifest(task)?;
284            let manifest = match (remote_manifest, local_manifest) {
285                (Some(remote), Some(local)) => {
286                    let (manifest, merged) = merge_remote_task_manifest(task, remote, local);
287                    if !merged {
288                        remote_etag = None;
289                    }
290                    Some(manifest)
291                }
292                (Some(remote), None) => Some(remote),
293                (None, local) => local,
294            };
295            if let Some(manifest) = &manifest {
296                self.persist_task_manifest(manifest)?;
297            }
298            manifest
299        };
300        let state = if let Some(manifest) = manifest {
301            TaskActionState {
302                manifest: task.to_string(),
303                baseline_loaded: true,
304                predictions: manifest
305                    .predictions
306                    .into_iter()
307                    .map(|prediction| (prediction.invocation.clone(), prediction))
308                    .collect(),
309                remote_etag,
310            }
311        } else {
312            TaskActionState {
313                manifest: task.to_string(),
314                baseline_loaded: true,
315                remote_etag,
316                ..TaskActionState::default()
317            }
318        };
319        let sequence = self.next_task_run.fetch_add(1, Ordering::Relaxed);
320        let run =
321            CacheDigest::blake3(format!("{task}\0{}\0{sequence}", std::process::id()).as_bytes())
322                .hash;
323        let predictions = state.predictions.values().cloned().collect();
324        self.task_actions.lock().unwrap().insert(run.clone(), state);
325        self.spawn_prefetch_predictions(predictions);
326        Ok(run)
327    }
328
329    /// Cancel speculative downloads before the owning session exits.
330    pub async fn cancel_prefetches(&self) {
331        let tasks = std::mem::take(&mut *self.prefetch_tasks.lock().unwrap());
332        for task in &tasks {
333            task.abort();
334        }
335        for task in tasks {
336            if let Err(error) = task.await
337                && !error.is_cancelled()
338            {
339                warn!("remote action prefetch task failed: {error}");
340            }
341        }
342    }
343
344    #[cfg(test)]
345    async fn wait_for_prefetches(&self) {
346        let tasks = std::mem::take(&mut *self.prefetch_tasks.lock().unwrap());
347        for task in tasks {
348            if let Err(error) = task.await {
349                warn!("remote action prefetch task failed: {error}");
350            }
351        }
352    }
353
354    /// Atomically publish the candidate manifest collected by a successful task.
355    pub async fn commit_task(&self, run: &str) -> Result<()> {
356        validate_task_identity(run)?;
357        let state = self
358            .task_actions
359            .lock()
360            .unwrap()
361            .get(run)
362            .cloned()
363            .ok_or_else(|| eyre::eyre!("task action manifest baseline was not loaded"))?;
364        if !state.baseline_loaded {
365            bail!("task action manifest baseline was not loaded");
366        }
367        let task = state.manifest;
368        validate_task_identity(&task)?;
369        let manifest = {
370            let _write_guard = self.manifest_write_lock.lock().unwrap();
371            let _file_guard = self.lock_task_manifest(&task)?;
372            let mut predictions = self
373                .load_task_manifest(&task)?
374                .map(|manifest| {
375                    manifest
376                        .predictions
377                        .into_iter()
378                        .map(|prediction| (prediction.invocation.clone(), prediction))
379                        .collect::<BTreeMap<_, _>>()
380                })
381                .unwrap_or_default();
382            predictions.extend(state.predictions);
383            let manifest = TaskActionManifest {
384                version: TASK_ACTION_MANIFEST_VERSION,
385                task: task.clone(),
386                predictions: predictions.into_values().collect(),
387            };
388            validate_task_manifest(&manifest, &task)?;
389            self.persist_task_manifest(&manifest)?;
390            manifest
391        };
392        self.task_actions.lock().unwrap().remove(run);
393        if self.remote_mode.writes() {
394            match self
395                .put_remote_task_manifest(&task, manifest, state.remote_etag)
396                .await
397            {
398                Ok(remote_manifest) => {
399                    let _write_guard = self.manifest_write_lock.lock().unwrap();
400                    let reconciliation = (|| {
401                        let _file_guard = self.lock_task_manifest(&task)?;
402                        let manifest = match self.load_task_manifest(&task)? {
403                            Some(local) => {
404                                merge_remote_task_manifest(&task, remote_manifest, local).0
405                            }
406                            None => remote_manifest,
407                        };
408                        self.persist_task_manifest(&manifest)
409                    })();
410                    if let Err(error) = reconciliation {
411                        warn!(
412                            "remote task action manifest reconciliation failed for {task}: {error}"
413                        );
414                    }
415                }
416                Err(error) => {
417                    warn!("remote task action manifest upload failed for {task}: {error}");
418                }
419            }
420        }
421        Ok(())
422    }
423
424    fn task_manifest_path(&self, task: &str) -> PathBuf {
425        self.manifest_dir.join(format!("{task}.json"))
426    }
427
428    fn task_manifest_lock_path(&self, task: &str) -> PathBuf {
429        self.manifest_dir.join("locks").join(format!("{task}.lock"))
430    }
431
432    fn lock_task_manifest(&self, task: &str) -> Result<fslock::LockFile> {
433        let path = self.task_manifest_lock_path(task);
434        fs::create_dir_all(path.parent().expect("task manifest lock has a parent"))?;
435        let mut lock = fslock::LockFile::open(&path)?;
436        lock.lock()?;
437        Ok(lock)
438    }
439
440    fn load_task_manifest(&self, task: &str) -> Result<Option<TaskActionManifest>> {
441        match fs::read(self.task_manifest_path(task)) {
442            Ok(contents) => Ok(Some(self.parse_task_manifest(task, &contents, false)?)),
443            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
444            Err(error) => Err(error.into()),
445        }
446    }
447
448    fn parse_task_manifest(
449        &self,
450        task: &str,
451        contents: &[u8],
452        require_canonical: bool,
453    ) -> Result<TaskActionManifest> {
454        let manifest: TaskActionManifest = serde_json::from_slice(contents)?;
455        validate_task_manifest(&manifest, task)?;
456        if require_canonical && canonical_json(&manifest)? != contents {
457            bail!("task action manifest is not canonical JSON");
458        }
459        Ok(manifest)
460    }
461
462    fn task_manifest_selector(task: &str) -> Result<(Vec<u8>, CacheDigest)> {
463        let bytes = canonical_json(&TaskActionManifestSelector {
464            version: 1,
465            kind: "task_action_manifest",
466            task,
467        })?;
468        let digest = CacheDigest::blake3(&bytes);
469        Ok((bytes, digest))
470    }
471
472    fn persist_task_manifest(&self, manifest: &TaskActionManifest) -> Result<()> {
473        let bytes = canonical_json(manifest)?;
474        fs::create_dir_all(self.manifest_dir.as_path())?;
475        let mut temporary = tempfile::NamedTempFile::new_in(self.manifest_dir.as_path())?;
476        std::io::Write::write_all(temporary.as_file_mut(), &bytes)?;
477        temporary.as_file_mut().sync_all()?;
478        temporary
479            .persist(self.task_manifest_path(&manifest.task))
480            .map_err(|error| error.error)?;
481        Ok(())
482    }
483
484    async fn get_remote_task_manifest(
485        &self,
486        task: &str,
487    ) -> Result<Option<(TaskActionManifest, String)>> {
488        let Some(remote) = &self.remote else {
489            return Ok(None);
490        };
491        let (_, selector) = Self::task_manifest_selector(task)?;
492        let _permit = self.remote_transfers.acquire().await?;
493        let Some(remote_manifest) = remote.get_action_manifest(&selector).await? else {
494            return Ok(None);
495        };
496        let manifest = self.parse_task_manifest(task, &remote_manifest.bytes, true)?;
497        Ok(Some((manifest, remote_manifest.etag)))
498    }
499
500    async fn put_remote_task_manifest(
501        &self,
502        task: &str,
503        mut manifest: TaskActionManifest,
504        mut expected_etag: Option<String>,
505    ) -> Result<TaskActionManifest> {
506        let Some(remote) = &self.remote else {
507            return Ok(manifest);
508        };
509        let (_, selector) = Self::task_manifest_selector(task)?;
510        for _ in 0..4 {
511            let bytes = canonical_json(&manifest)?;
512            let outcome = {
513                let _permit = self.remote_transfers.acquire().await?;
514                remote
515                    .put_action_manifest(&selector, &bytes, expected_etag.as_deref())
516                    .await?
517            };
518            match outcome {
519                ManifestPutOutcome::Stored => return Ok(manifest),
520                ManifestPutOutcome::PreconditionFailed => {
521                    let Some((remote_manifest, etag)) = self.get_remote_task_manifest(task).await?
522                    else {
523                        expected_etag = None;
524                        continue;
525                    };
526                    manifest = merge_task_manifests(task, Some(remote_manifest), manifest)?;
527                    expected_etag = Some(etag);
528                }
529            }
530        }
531        bail!("remote task action manifest changed too frequently")
532    }
533
534    /// Return a snapshot of this session's cache activity.
535    pub fn stats(&self) -> AgentStats {
536        AgentStats {
537            lookups: self.stats.lookups.load(Ordering::Relaxed),
538            hits: self.stats.hits.load(Ordering::Relaxed),
539            stores: self.stats.stores.load(Ordering::Relaxed),
540            stored_bytes: self.stats.stored_bytes.load(Ordering::Relaxed),
541            verifications: self.stats.verifications.load(Ordering::Relaxed),
542            divergences: self.stats.divergences.load(Ordering::Relaxed),
543            downloaded_bytes: self.stats.downloaded_bytes.load(Ordering::Relaxed),
544            uploaded_bytes: self.stats.uploaded_bytes.load(Ordering::Relaxed),
545            prefetched_actions: self.stats.prefetched_actions.load(Ordering::Relaxed),
546        }
547    }
548
549    fn write_lock(&self, digest: &CacheDigest) -> Arc<tokio::sync::Mutex<()>> {
550        Self::digest_lock(&self.write_locks, digest)
551    }
552
553    fn action_lock(&self, digest: &CacheDigest) -> Arc<tokio::sync::Mutex<()>> {
554        Self::digest_lock(&self.action_locks, digest)
555    }
556
557    fn digest_lock(
558        locks: &Mutex<BTreeMap<CacheDigest, Weak<tokio::sync::Mutex<()>>>>,
559        digest: &CacheDigest,
560    ) -> Arc<tokio::sync::Mutex<()>> {
561        let mut locks = locks.lock().unwrap();
562        locks.retain(|_, lock| lock.strong_count() > 0);
563        if let Some(lock) = locks.get(digest).and_then(Weak::upgrade) {
564            return lock;
565        }
566        let lock = Arc::new(tokio::sync::Mutex::new(()));
567        locks.insert(digest.clone(), Arc::downgrade(&lock));
568        lock
569    }
570
571    fn spawn_prefetch_predictions(&self, predictions: Vec<ActionPrediction>) {
572        if predictions.is_empty() || !self.remote_mode.reads() || self.remote.is_none() {
573            return;
574        }
575        let agent = self.clone();
576        let task = tokio::spawn(async move {
577            agent.prefetch_predictions(predictions.iter()).await;
578        });
579        self.prefetch_tasks.lock().unwrap().push(task);
580    }
581
582    async fn prefetch_predictions<'a>(
583        &self,
584        predictions: impl Iterator<Item = &'a ActionPrediction>,
585    ) {
586        if !self.remote_mode.reads() || self.remote.is_none() {
587            return;
588        }
589        let mut actions = BTreeMap::new();
590        for prediction in predictions {
591            actions
592                .entry(prediction.action.clone())
593                .or_insert_with(|| prediction.adapter.clone());
594        }
595        let mut actions = actions.into_iter();
596        let mut tasks = tokio::task::JoinSet::new();
597        for _ in 0..MAX_PREFETCH_TRANSFERS {
598            let Some((action, adapter)) = actions.next() else {
599                break;
600            };
601            let agent = self.clone();
602            tasks.spawn(async move { agent.prefetch_action(action, adapter).await });
603        }
604        while let Some(result) = tasks.join_next().await {
605            match result {
606                Ok(Ok(())) => {}
607                Ok(Err(error)) => warn!("remote action prefetch failed: {error}"),
608                Err(error) => warn!("remote action prefetch task failed: {error}"),
609            }
610            if let Some((action, adapter)) = actions.next() {
611                let agent = self.clone();
612                tasks.spawn(async move { agent.prefetch_action(action, adapter).await });
613            }
614        }
615    }
616
617    async fn prefetch_action(&self, action: CacheDigest, adapter: String) -> Result<()> {
618        let remote = self
619            .remote
620            .as_ref()
621            .ok_or_else(|| eyre::eyre!("remote cache is not configured"))?;
622        let result = {
623            let lock = self.action_lock(&action);
624            let _guard = lock.lock().await;
625            if self.actions.find(&action)?.is_some() {
626                return Ok(());
627            }
628            if let Some(result) = self
629                .pending_remote_actions
630                .lock()
631                .unwrap()
632                .get(&action)
633                .cloned()
634            {
635                result
636            } else {
637                let _prefetch_permit = self.prefetch_transfers.acquire().await?;
638                let result = {
639                    let _permit = self.remote_transfers.acquire().await?;
640                    remote.get_action_result(&action).await?
641                };
642                let Some(result) = result else { return Ok(()) };
643                self.pending_remote_actions
644                    .lock()
645                    .unwrap()
646                    .insert(action.clone(), result.clone());
647                result
648            }
649        };
650        self.fetch_remote_blob_prefetch(remote, &result.action)
651            .await?;
652        if let Some(metadata) = &result.metadata {
653            let path = self.fetch_remote_blob_prefetch(remote, metadata).await?;
654            if adapter == "rustc" {
655                let bytes = fs::read(path)?;
656                let metadata: RustcMetadata = serde_json::from_slice(&bytes)?;
657                if metadata.version != 1
658                    || metadata.kind != "rustc"
659                    || canonical_json(&metadata)? != bytes
660                {
661                    bail!("remote rustc action metadata is invalid");
662                }
663                self.fetch_remote_blob_prefetch(remote, &metadata.stdout)
664                    .await?;
665                self.fetch_remote_blob_prefetch(remote, &metadata.stderr)
666                    .await?;
667            }
668        }
669        if let Some(output_root) = &result.output_root {
670            self.prefetch_output_tree(remote, output_root).await?;
671        }
672        self.actions.store(&result)?;
673        self.pending_remote_actions.lock().unwrap().remove(&action);
674        self.stats
675            .prefetched_actions
676            .fetch_add(1, Ordering::Relaxed);
677        Ok(())
678    }
679
680    async fn prefetch_output_tree(
681        &self,
682        remote: &RemoteCacheClient,
683        output_root: &CacheDigest,
684    ) -> Result<()> {
685        let mut pending = vec![output_root.clone()];
686        let mut seen = BTreeMap::new();
687        while let Some(digest) = pending.pop() {
688            if seen.insert(digest.clone(), ()).is_some() {
689                continue;
690            }
691            if seen.len() > MAX_PREFETCH_DIRECTORY_OBJECTS {
692                bail!("remote action output tree is too large");
693            }
694            let path = self.fetch_remote_blob_prefetch(remote, &digest).await?;
695            let bytes = fs::read(path)?;
696            let directory: CacheDirectory = serde_json::from_slice(&bytes)?;
697            if directory.version != 1 || canonical_json(&directory)? != bytes {
698                bail!("remote action output directory is invalid");
699            }
700            let mut transfers = stream::iter(directory.files.into_iter().map(|file| async move {
701                self.fetch_remote_blob_prefetch(remote, &file.digest)
702                    .await
703                    .map(|_| ())
704            }))
705            .buffer_unordered(MAX_PREFETCH_TRANSFERS);
706            while let Some(result) = transfers.next().await {
707                result?;
708            }
709            pending.extend(
710                directory
711                    .directories
712                    .into_iter()
713                    .map(|directory| directory.digest),
714            );
715        }
716        Ok(())
717    }
718
719    async fn fetch_remote_blob_prefetch(
720        &self,
721        remote: &RemoteCacheClient,
722        digest: &CacheDigest,
723    ) -> Result<PathBuf> {
724        self.fetch_remote_blob_with_limit(remote, digest, Some(&self.prefetch_transfers))
725            .await
726    }
727
728    async fn fetch_remote_blob(
729        &self,
730        remote: &RemoteCacheClient,
731        digest: &CacheDigest,
732    ) -> Result<PathBuf> {
733        self.fetch_remote_blob_with_limit(remote, digest, None)
734            .await
735    }
736
737    async fn fetch_remote_blob_with_limit(
738        &self,
739        remote: &RemoteCacheClient,
740        digest: &CacheDigest,
741        prefetch_limit: Option<&tokio::sync::Semaphore>,
742    ) -> Result<PathBuf> {
743        let lock = self.write_lock(digest);
744        let _guard = lock.lock().await;
745        if let Some(path) = self.cas.find(digest)? {
746            return Ok(path);
747        }
748        let _prefetch_permit = match prefetch_limit {
749            Some(limit) => Some(limit.acquire().await?),
750            None => None,
751        };
752        let _permit = self.remote_transfers.acquire().await?;
753        let temporary = remote
754            .get_blob_file(digest, self.remote_staging_dir.as_path())
755            .await?;
756        let path = self.cas.store_file(digest, temporary.path())?;
757        self.stats.stores.fetch_add(1, Ordering::Relaxed);
758        self.stats
759            .stored_bytes
760            .fetch_add(digest.size, Ordering::Relaxed);
761        self.stats
762            .downloaded_bytes
763            .fetch_add(digest.size, Ordering::Relaxed);
764        Ok(path)
765    }
766
767    async fn respond(&self, request: AgentRequest) -> AgentResponse {
768        let result = match request {
769            AgentRequest::FindBlob { digest } => self.find_blob(&digest).await,
770            AgentRequest::StoreBlob { digest, source } => self.store_blob(&digest, &source).await,
771            AgentRequest::FindActionResult { action } => {
772                self.stats.lookups.fetch_add(1, Ordering::Relaxed);
773                self.find_action_result(&action).await
774            }
775            AgentRequest::RecordActionHit { action } => self.record_action_hit(&action),
776            AgentRequest::RecordActionVerification { matched } => {
777                self.stats.verifications.fetch_add(1, Ordering::Relaxed);
778                if !matched {
779                    self.stats.divergences.fetch_add(1, Ordering::Relaxed);
780                }
781                Ok(AgentResponse::ActionVerificationRecorded)
782            }
783            AgentRequest::StoreActionResult { result } => self.store_action_result(&result).await,
784            AgentRequest::FindActionPrediction { task, invocation } => {
785                self.find_action_prediction(&task, &invocation)
786            }
787            AgentRequest::RecordActionPrediction { task, prediction } => {
788                self.record_action_prediction(&task, prediction)
789            }
790            AgentRequest::FindExecutableIdentity {
791                executable,
792                environment,
793            } => self.find_executable_identity(executable, environment),
794            AgentRequest::StoreExecutableIdentity {
795                executable,
796                environment,
797                stdout,
798            } => self.store_executable_identity(executable, environment, stdout),
799            AgentRequest::Hello { .. } => {
800                Err(eyre::eyre!("hello is only valid as the first request"))
801            }
802        };
803        result.unwrap_or_else(|error| AgentResponse::Error {
804            message: error.to_string(),
805        })
806    }
807
808    async fn find_blob(&self, digest: &CacheDigest) -> Result<AgentResponse> {
809        if let Some(path) = self.cas.find(digest)? {
810            return Ok(AgentResponse::Blob { path: Some(path) });
811        }
812        if !self.remote_mode.reads() {
813            return Ok(AgentResponse::Blob { path: None });
814        }
815        let Some(remote) = &self.remote else {
816            return Ok(AgentResponse::Blob { path: None });
817        };
818        match self.fetch_remote_blob(remote, digest).await {
819            Ok(path) => Ok(AgentResponse::Blob { path: Some(path) }),
820            Err(error) => {
821                warn!(
822                    "remote cache blob lookup failed for {}: {error}",
823                    digest.hash
824                );
825                Ok(AgentResponse::Blob { path: None })
826            }
827        }
828    }
829
830    async fn store_blob(&self, digest: &CacheDigest, source: &Path) -> Result<AgentResponse> {
831        let remote = if self.remote_mode.writes() {
832            self.remote.as_deref()
833        } else {
834            None
835        };
836        let path = {
837            let lock = self.write_lock(digest);
838            let _guard = lock.lock().await;
839            if let Some(path) = self.cas.find(digest)? {
840                path
841            } else {
842                let path = self.cas.store_file(digest, source)?;
843                self.stats.stores.fetch_add(1, Ordering::Relaxed);
844                self.stats
845                    .stored_bytes
846                    .fetch_add(digest.size, Ordering::Relaxed);
847                path
848            }
849        };
850        if let Some(remote) = remote {
851            let _permit = self.remote_transfers.acquire().await?;
852            if let Err(error) = remote
853                .put_blob(&BlobUpload {
854                    digest: digest.clone(),
855                    source: BlobSource::Path(path.clone()),
856                })
857                .await
858            {
859                warn!(
860                    "remote cache blob upload failed for {}: {error}",
861                    digest.hash
862                );
863            } else {
864                self.stats
865                    .uploaded_bytes
866                    .fetch_add(digest.size, Ordering::Relaxed);
867            }
868        }
869        Ok(AgentResponse::Stored { path })
870    }
871
872    async fn find_action_result(&self, action: &CacheDigest) -> Result<AgentResponse> {
873        if let Some(result) = self.actions.find(action)? {
874            return Ok(AgentResponse::ActionResult {
875                result: Some(result),
876            });
877        }
878        if !self.remote_mode.reads() {
879            return Ok(AgentResponse::ActionResult { result: None });
880        }
881        let Some(remote) = &self.remote else {
882            return Ok(AgentResponse::ActionResult { result: None });
883        };
884        let lock = self.action_lock(action);
885        let _guard = lock.lock().await;
886        if let Some(result) = self.actions.find(action)? {
887            return Ok(AgentResponse::ActionResult {
888                result: Some(result),
889            });
890        }
891        if let Some(result) = self
892            .pending_remote_actions
893            .lock()
894            .unwrap()
895            .get(action)
896            .cloned()
897        {
898            return Ok(AgentResponse::ActionResult {
899                result: Some(result),
900            });
901        }
902        let _permit = self.remote_transfers.acquire().await?;
903        match remote.get_action_result(action).await {
904            Ok(Some(result)) => {
905                self.pending_remote_actions
906                    .lock()
907                    .unwrap()
908                    .insert(action.clone(), result.clone());
909                Ok(AgentResponse::ActionResult {
910                    result: Some(result),
911                })
912            }
913            Ok(None) => Ok(AgentResponse::ActionResult { result: None }),
914            Err(error) => {
915                warn!(
916                    "remote cache action lookup failed for {}: {error}",
917                    action.hash
918                );
919                Ok(AgentResponse::ActionResult { result: None })
920            }
921        }
922    }
923
924    async fn store_action_result(&self, result: &RemoteActionResult) -> Result<AgentResponse> {
925        let path = self.actions.store(result)?;
926        if self.remote_mode.writes()
927            && let Some(remote) = &self.remote
928        {
929            let _permit = self.remote_transfers.acquire().await?;
930            if let Err(error) = remote.put_action_result(result).await {
931                warn!(
932                    "remote cache action upload failed for {}: {error}",
933                    result.action.hash
934                );
935            }
936        }
937        Ok(AgentResponse::ActionStored { path })
938    }
939
940    fn record_action_hit(&self, action: &CacheDigest) -> Result<AgentResponse> {
941        if self.actions.find(action)?.is_none() {
942            let pending = self.pending_remote_actions.lock().unwrap().remove(action);
943            if let Some(result) = pending {
944                self.actions.store(&result)?;
945            } else {
946                bail!("cannot record a hit for a missing action result");
947            }
948        }
949        self.stats.hits.fetch_add(1, Ordering::Relaxed);
950        Ok(AgentResponse::ActionHitRecorded)
951    }
952
953    fn find_action_prediction(
954        &self,
955        task: &str,
956        invocation: &CacheDigest,
957    ) -> Result<AgentResponse> {
958        validate_task_identity(task)?;
959        invocation.validate()?;
960        let prediction = self
961            .task_actions
962            .lock()
963            .unwrap()
964            .get(task)
965            .and_then(|state| state.predictions.get(invocation))
966            .cloned();
967        Ok(AgentResponse::ActionPrediction { prediction })
968    }
969
970    fn record_action_prediction(
971        &self,
972        task: &str,
973        prediction: ActionPrediction,
974    ) -> Result<AgentResponse> {
975        validate_task_identity(task)?;
976        validate_action_prediction(&prediction)?;
977        let mut tasks = self.task_actions.lock().unwrap();
978        let state = tasks.entry(task.to_string()).or_default();
979        if !state.predictions.contains_key(&prediction.invocation)
980            && state.predictions.len() >= MAX_TASK_ACTION_PREDICTIONS
981        {
982            bail!("task action manifest contains too many predictions");
983        }
984        state
985            .predictions
986            .insert(prediction.invocation.clone(), prediction);
987        Ok(AgentResponse::ActionPredictionRecorded)
988    }
989
990    fn executable_identity_key(
991        &self,
992        executable: PathBuf,
993        environment: BTreeMap<String, Option<String>>,
994    ) -> Result<ExecutableIdentityKey> {
995        if !environment
996            .keys()
997            .all(|name| matches!(name.as_str(), "RUSTUP_HOME" | "RUSTUP_TOOLCHAIN"))
998        {
999            bail!("executable identity contains an unsupported environment variable");
1000        }
1001        Ok(ExecutableIdentityKey {
1002            executable,
1003            environment,
1004        })
1005    }
1006
1007    fn find_executable_identity(
1008        &self,
1009        executable: PathBuf,
1010        environment: BTreeMap<String, Option<String>>,
1011    ) -> Result<AgentResponse> {
1012        let key = self.executable_identity_key(executable, environment)?;
1013        let stdout = self
1014            .executable_identities
1015            .lock()
1016            .unwrap()
1017            .get(&key)
1018            .cloned();
1019        Ok(AgentResponse::ExecutableIdentity { stdout })
1020    }
1021
1022    fn store_executable_identity(
1023        &self,
1024        executable: PathBuf,
1025        environment: BTreeMap<String, Option<String>>,
1026        stdout: Vec<u8>,
1027    ) -> Result<AgentResponse> {
1028        if stdout.len() > MAX_EXECUTABLE_IDENTITY_SIZE {
1029            bail!("executable identity exceeds {MAX_EXECUTABLE_IDENTITY_SIZE} bytes");
1030        }
1031        let key = self.executable_identity_key(executable, environment)?;
1032        let mut identities = self.executable_identities.lock().unwrap();
1033        let is_new = !identities.contains_key(&key);
1034        let previous_size = identities.get(&key).map_or(0, Vec::len);
1035        if is_new && identities.len() >= MAX_EXECUTABLE_IDENTITIES {
1036            bail!("executable identity cache contains too many entries");
1037        }
1038        let retained_bytes = identities.values().map(Vec::len).sum::<usize>();
1039        if retained_bytes - previous_size + stdout.len() > MAX_EXECUTABLE_IDENTITY_BYTES {
1040            bail!("executable identity cache contains too many bytes");
1041        }
1042        identities.insert(key, stdout.clone());
1043        Ok(AgentResponse::ExecutableIdentity {
1044            stdout: Some(stdout),
1045        })
1046    }
1047
1048    /// Serve newline-delimited protocol requests on an authenticated session stream.
1049    pub async fn handle_connection<S>(&self, stream: S) -> Result<()>
1050    where
1051        S: AsyncRead + AsyncWrite + Unpin,
1052    {
1053        let (reader, mut writer) = tokio::io::split(stream);
1054        let mut lines = BufReader::new(reader).lines();
1055        let hello = lines
1056            .next_line()
1057            .await?
1058            .ok_or_else(|| eyre::eyre!("connection closed before the agent handshake"))?;
1059        let request: AgentRequest = serde_json::from_str(&hello)?;
1060        match request {
1061            AgentRequest::Hello {
1062                protocol,
1063                client_version,
1064            } if protocol == AGENT_PROTOCOL_VERSION && client_version == self.version.as_ref() => {}
1065            AgentRequest::Hello { protocol, .. } if protocol != AGENT_PROTOCOL_VERSION => {
1066                send_response(
1067                    &mut writer,
1068                    &AgentResponse::Error {
1069                        message: format!(
1070                            "unsupported agent protocol {protocol}; expected {AGENT_PROTOCOL_VERSION}"
1071                        ),
1072                    },
1073                )
1074                .await?;
1075                return Ok(());
1076            }
1077            AgentRequest::Hello { client_version, .. } => {
1078                send_response(
1079                    &mut writer,
1080                    &AgentResponse::Error {
1081                        message: format!(
1082                            "cache client {client_version} does not match agent {}",
1083                            self.version
1084                        ),
1085                    },
1086                )
1087                .await?;
1088                return Ok(());
1089            }
1090            _ => bail!("the first agent request must be hello"),
1091        }
1092        send_response(
1093            &mut writer,
1094            &AgentResponse::Hello {
1095                protocol: AGENT_PROTOCOL_VERSION,
1096                agent_version: self.version.to_string(),
1097            },
1098        )
1099        .await?;
1100
1101        while let Some(line) = lines.next_line().await? {
1102            let response = match serde_json::from_str(&line) {
1103                Ok(request) => self.respond(request).await,
1104                Err(error) => AgentResponse::Error {
1105                    message: format!("invalid agent request: {error}"),
1106                },
1107            };
1108            send_response(&mut writer, &response).await?;
1109        }
1110        Ok(())
1111    }
1112}
1113
1114fn validate_task_identity(task: &str) -> Result<()> {
1115    if task.len() != 64
1116        || !task
1117            .bytes()
1118            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1119    {
1120        bail!("invalid task action identity");
1121    }
1122    Ok(())
1123}
1124
1125fn validate_action_prediction(prediction: &ActionPrediction) -> Result<()> {
1126    prediction.invocation.validate()?;
1127    prediction.action.validate()?;
1128    if prediction.adapter.is_empty()
1129        || !prediction
1130            .adapter
1131            .bytes()
1132            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1133    {
1134        bail!("invalid action prediction adapter");
1135    }
1136    if prediction.payload.len() > MAX_ACTION_PREDICTION_PAYLOAD {
1137        bail!("action prediction payload is too large");
1138    }
1139    serde_json::from_str::<serde_json::Value>(&prediction.payload)?;
1140    Ok(())
1141}
1142
1143fn validate_task_manifest(manifest: &TaskActionManifest, task: &str) -> Result<()> {
1144    if manifest.version != TASK_ACTION_MANIFEST_VERSION || manifest.task != task {
1145        bail!("task action manifest has an invalid identity");
1146    }
1147    if manifest.predictions.len() > MAX_TASK_ACTION_PREDICTIONS {
1148        bail!("task action manifest contains too many predictions");
1149    }
1150    let mut invocations = BTreeMap::new();
1151    for prediction in &manifest.predictions {
1152        validate_action_prediction(prediction)?;
1153        if invocations.insert(&prediction.invocation, ()).is_some() {
1154            bail!("task action manifest contains duplicate predictions");
1155        }
1156    }
1157    Ok(())
1158}
1159
1160fn merge_task_manifests(
1161    task: &str,
1162    base: Option<TaskActionManifest>,
1163    update: TaskActionManifest,
1164) -> Result<TaskActionManifest> {
1165    validate_task_manifest(&update, task)?;
1166    let mut predictions = BTreeMap::new();
1167    if let Some(base) = base {
1168        validate_task_manifest(&base, task)?;
1169        predictions.extend(
1170            base.predictions
1171                .into_iter()
1172                .map(|prediction| (prediction.invocation.clone(), prediction)),
1173        );
1174    }
1175    predictions.extend(
1176        update
1177            .predictions
1178            .into_iter()
1179            .map(|prediction| (prediction.invocation.clone(), prediction)),
1180    );
1181    let manifest = TaskActionManifest {
1182        version: TASK_ACTION_MANIFEST_VERSION,
1183        task: task.to_owned(),
1184        predictions: predictions.into_values().collect(),
1185    };
1186    validate_task_manifest(&manifest, task)?;
1187    Ok(manifest)
1188}
1189
1190fn merge_remote_task_manifest(
1191    task: &str,
1192    remote: TaskActionManifest,
1193    local: TaskActionManifest,
1194) -> (TaskActionManifest, bool) {
1195    match merge_task_manifests(task, Some(remote), local.clone()) {
1196        Ok(manifest) => (manifest, true),
1197        Err(error) => {
1198            warn!("remote task action manifest merge failed for {task}: {error}");
1199            (local, false)
1200        }
1201    }
1202}
1203
1204async fn send_response(
1205    writer: &mut (impl AsyncWrite + Unpin),
1206    response: &AgentResponse,
1207) -> Result<()> {
1208    let mut encoded = serde_json::to_vec(response)?;
1209    encoded.push(b'\n');
1210    writer.write_all(&encoded).await?;
1211    writer.flush().await?;
1212    Ok(())
1213}
1214
1215#[cfg(test)]
1216mod tests {
1217    use super::*;
1218    use crate::ACTION_RESULT_MEDIA_TYPE;
1219    use std::time::Duration;
1220
1221    async fn handshake(stream: &mut (impl AsyncRead + AsyncWrite + Unpin), version: &str) {
1222        let request = AgentRequest::Hello {
1223            protocol: AGENT_PROTOCOL_VERSION,
1224            client_version: version.to_string(),
1225        };
1226        let mut encoded = serde_json::to_vec(&request).unwrap();
1227        encoded.push(b'\n');
1228        stream.write_all(&encoded).await.unwrap();
1229        stream.flush().await.unwrap();
1230        let mut response = String::new();
1231        BufReader::new(stream)
1232            .read_line(&mut response)
1233            .await
1234            .unwrap();
1235        assert!(matches!(
1236            serde_json::from_str(&response).unwrap(),
1237            AgentResponse::Hello { .. }
1238        ));
1239    }
1240
1241    #[tokio::test]
1242    async fn handshake_and_blob_round_trip() {
1243        let directory = tempfile::tempdir().unwrap();
1244        let source = directory.path().join("source");
1245        std::fs::write(&source, b"cached object").unwrap();
1246        let digest = CacheDigest::blake3(b"cached object");
1247        let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
1248        let (mut client, server) = tokio::io::duplex(16 * 1024);
1249        let server_agent = agent.clone();
1250        let task = tokio::spawn(async move { server_agent.handle_connection(server).await });
1251
1252        handshake(&mut client, "test-version").await;
1253        let request = AgentRequest::StoreBlob {
1254            digest: digest.clone(),
1255            source,
1256        };
1257        let mut encoded = serde_json::to_vec(&request).unwrap();
1258        encoded.push(b'\n');
1259        client.write_all(&encoded).await.unwrap();
1260        let mut response = String::new();
1261        BufReader::new(&mut client)
1262            .read_line(&mut response)
1263            .await
1264            .unwrap();
1265        assert!(matches!(
1266            serde_json::from_str(&response).unwrap(),
1267            AgentResponse::Stored { .. }
1268        ));
1269        drop(client);
1270        task.await.unwrap().unwrap();
1271        assert_eq!(
1272            agent.stats(),
1273            AgentStats {
1274                stores: 1,
1275                stored_bytes: digest.size,
1276                ..AgentStats::default()
1277            }
1278        );
1279    }
1280
1281    #[tokio::test]
1282    async fn publishes_a_complete_action_result() {
1283        let directory = tempfile::tempdir().unwrap();
1284        let agent = CacheAgent::new(directory.path().join("cache"), "test-version");
1285        let action = CacheDigest::blake3(b"action");
1286        let metadata = CacheDigest::blake3(b"metadata");
1287        let output_root = CacheDigest::blake3(b"directory");
1288        for (digest, contents) in [
1289            (&action, b"action".as_slice()),
1290            (&metadata, b"metadata".as_slice()),
1291            (&output_root, b"directory".as_slice()),
1292        ] {
1293            agent.cas.store_bytes(digest, contents).unwrap();
1294        }
1295        let response = agent
1296            .respond(AgentRequest::StoreActionResult {
1297                result: RemoteActionResult {
1298                    action: action.clone(),
1299                    metadata: Some(metadata),
1300                    output_root: Some(output_root),
1301                    version: 1,
1302                },
1303            })
1304            .await;
1305        assert!(matches!(response, AgentResponse::ActionStored { .. }));
1306        let response = agent
1307            .respond(AgentRequest::FindActionResult {
1308                action: action.clone(),
1309            })
1310            .await;
1311        assert!(matches!(
1312            response,
1313            AgentResponse::ActionResult {
1314                result: Some(result)
1315            } if result.action == action
1316        ));
1317        assert!(matches!(
1318            agent
1319                .respond(AgentRequest::RecordActionHit {
1320                    action: action.clone()
1321                })
1322                .await,
1323            AgentResponse::ActionHitRecorded
1324        ));
1325        assert_eq!(
1326            agent.stats(),
1327            AgentStats {
1328                lookups: 1,
1329                hits: 1,
1330                ..AgentStats::default()
1331            }
1332        );
1333    }
1334
1335    #[tokio::test]
1336    async fn missing_action_result_is_a_cache_miss() {
1337        let directory = tempfile::tempdir().unwrap();
1338        let agent = CacheAgent::new(directory.path(), "test-version");
1339        let action = CacheDigest::blake3(b"missing action");
1340        let response = agent
1341            .respond(AgentRequest::FindActionResult {
1342                action: action.clone(),
1343            })
1344            .await;
1345
1346        assert!(matches!(
1347            response,
1348            AgentResponse::ActionResult { result: None }
1349        ));
1350        assert!(matches!(
1351            agent
1352                .respond(AgentRequest::RecordActionHit { action })
1353                .await,
1354            AgentResponse::Error { .. }
1355        ));
1356        assert_eq!(
1357            agent.stats(),
1358            AgentStats {
1359                lookups: 1,
1360                ..AgentStats::default()
1361            }
1362        );
1363
1364        assert!(matches!(
1365            agent
1366                .respond(AgentRequest::RecordActionVerification { matched: false })
1367                .await,
1368            AgentResponse::ActionVerificationRecorded
1369        ));
1370        assert_eq!(agent.stats().verifications, 1);
1371        assert_eq!(agent.stats().divergences, 1);
1372    }
1373
1374    #[tokio::test]
1375    async fn coalesces_repeated_remote_action_lookups() {
1376        let directory = tempfile::tempdir().unwrap();
1377        let mut server = mockito::Server::new_async().await;
1378        let action = CacheDigest::blake3(b"remote action");
1379        let result = RemoteActionResult {
1380            action: action.clone(),
1381            metadata: None,
1382            output_root: None,
1383            version: 1,
1384        };
1385        let remote = server
1386            .mock("GET", action_path(&action).as_str())
1387            .with_status(200)
1388            .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
1389            .with_body(serde_json::to_vec(&result).unwrap())
1390            .expect(1)
1391            .create_async()
1392            .await;
1393        let agent = remote_agent(
1394            &server,
1395            directory.path().join("reader"),
1396            RemoteCacheMode::ReadOnly,
1397        );
1398
1399        for _ in 0..2 {
1400            assert!(matches!(
1401                agent
1402                    .respond(AgentRequest::FindActionResult {
1403                        action: action.clone(),
1404                    })
1405                    .await,
1406                AgentResponse::ActionResult {
1407                    result: Some(found)
1408                } if found == result
1409            ));
1410        }
1411        remote.assert_async().await;
1412    }
1413
1414    #[tokio::test]
1415    async fn publishes_only_successfully_committed_task_action_manifests() {
1416        let directory = tempfile::tempdir().unwrap();
1417        let cache = directory.path().join("cache");
1418        let task = "a".repeat(64);
1419        let first_invocation = CacheDigest::blake3(b"first invocation");
1420        let first = ActionPrediction {
1421            invocation: first_invocation.clone(),
1422            action: CacheDigest::blake3(b"first action"),
1423            adapter: "rustc".into(),
1424            payload: "{}".into(),
1425        };
1426
1427        let agent = CacheAgent::new(&cache, "test-version");
1428        let first_run = agent.begin_task(&task).await.unwrap();
1429        assert!(matches!(
1430            agent
1431                .respond(AgentRequest::RecordActionPrediction {
1432                    task: first_run.clone(),
1433                    prediction: first.clone(),
1434                })
1435                .await,
1436            AgentResponse::ActionPredictionRecorded
1437        ));
1438        agent.commit_task(&first_run).await.unwrap();
1439
1440        let uncommitted = CacheAgent::new(&cache, "test-version");
1441        let uncommitted_run = uncommitted.begin_task(&task).await.unwrap();
1442        let second_invocation = CacheDigest::blake3(b"second invocation");
1443        assert!(matches!(
1444            uncommitted
1445                .respond(AgentRequest::RecordActionPrediction {
1446                    task: uncommitted_run,
1447                    prediction: ActionPrediction {
1448                        invocation: second_invocation.clone(),
1449                        action: CacheDigest::blake3(b"second action"),
1450                        adapter: "rustc".into(),
1451                        payload: "{}".into(),
1452                    },
1453                })
1454                .await,
1455            AgentResponse::ActionPredictionRecorded
1456        ));
1457
1458        let next_session = CacheAgent::new(&cache, "test-version");
1459        let next_run = next_session.begin_task(&task).await.unwrap();
1460        assert!(matches!(
1461            next_session
1462                .respond(AgentRequest::FindActionPrediction {
1463                    task: next_run.clone(),
1464                    invocation: first_invocation,
1465                })
1466                .await,
1467            AgentResponse::ActionPrediction {
1468                prediction: Some(prediction)
1469            } if prediction == first
1470        ));
1471        assert!(matches!(
1472            next_session
1473                .respond(AgentRequest::FindActionPrediction {
1474                    task: next_run,
1475                    invocation: second_invocation,
1476                })
1477                .await,
1478            AgentResponse::ActionPrediction { prediction: None }
1479        ));
1480
1481        let corrupt_task = "b".repeat(64);
1482        fs::create_dir_all(next_session.manifest_dir.as_path()).unwrap();
1483        fs::write(next_session.task_manifest_path(&corrupt_task), b"not json").unwrap();
1484        assert!(next_session.begin_task(&corrupt_task).await.is_err());
1485        let corrupt_run = "c".repeat(64);
1486        next_session.task_actions.lock().unwrap().insert(
1487            corrupt_run.clone(),
1488            TaskActionState {
1489                manifest: corrupt_task.clone(),
1490                ..TaskActionState::default()
1491            },
1492        );
1493        assert!(matches!(
1494            next_session
1495                .respond(AgentRequest::RecordActionPrediction {
1496                    task: corrupt_run.clone(),
1497                    prediction: first,
1498                })
1499                .await,
1500            AgentResponse::ActionPredictionRecorded
1501        ));
1502        assert!(next_session.commit_task(&corrupt_run).await.is_err());
1503        assert_eq!(
1504            fs::read(next_session.task_manifest_path(&corrupt_task)).unwrap(),
1505            b"not json"
1506        );
1507    }
1508
1509    #[tokio::test]
1510    async fn round_trips_task_actions_between_fresh_local_caches() {
1511        let directory = tempfile::tempdir().unwrap();
1512        let mut server = mockito::Server::new_async().await;
1513        let task = "e".repeat(64);
1514        let invocation = CacheDigest::blake3(b"remote invocation");
1515        let action_bytes = canonical_json(&serde_json::json!({"kind":"rustc"})).unwrap();
1516        let stdout_bytes = b"cached stdout".to_vec();
1517        let stderr_bytes = b"cached stderr".to_vec();
1518        let artifact_bytes = b"cached artifact".to_vec();
1519        let stdout = CacheDigest::blake3(&stdout_bytes);
1520        let stderr = CacheDigest::blake3(&stderr_bytes);
1521        let artifact = CacheDigest::blake3(&artifact_bytes);
1522        let metadata_bytes = canonical_json(&RustcMetadata {
1523            version: 1,
1524            kind: "rustc".into(),
1525            stdout: stdout.clone(),
1526            stderr: stderr.clone(),
1527        })
1528        .unwrap();
1529        let directory_bytes = canonical_json(&serde_json::json!({
1530            "directories":[],
1531            "files":[{"digest":artifact,"executable":false,"mode":420,"name":"artifact"}],
1532            "symlinks":[],
1533            "version":1
1534        }))
1535        .unwrap();
1536        let action = CacheDigest::blake3(&action_bytes);
1537        let metadata = CacheDigest::blake3(&metadata_bytes);
1538        let output_root = CacheDigest::blake3(&directory_bytes);
1539        let result = RemoteActionResult {
1540            action: action.clone(),
1541            metadata: Some(metadata.clone()),
1542            output_root: Some(output_root.clone()),
1543            version: 1,
1544        };
1545        let prediction = ActionPrediction {
1546            invocation: invocation.clone(),
1547            action: action.clone(),
1548            adapter: "rustc".into(),
1549            payload: "{}".into(),
1550        };
1551        let manifest_bytes = canonical_json(&TaskActionManifest {
1552            version: TASK_ACTION_MANIFEST_VERSION,
1553            task: task.clone(),
1554            predictions: vec![prediction.clone()],
1555        })
1556        .unwrap();
1557        let manifest_etag = blake3::hash(&manifest_bytes).to_hex().to_string();
1558        let (_, selector) = CacheAgent::task_manifest_selector(&task).unwrap();
1559
1560        let mut mocks = Vec::new();
1561        for (digest, bytes) in [
1562            (&action, action_bytes.as_slice()),
1563            (&metadata, metadata_bytes.as_slice()),
1564            (&output_root, directory_bytes.as_slice()),
1565            (&stdout, stdout_bytes.as_slice()),
1566            (&stderr, stderr_bytes.as_slice()),
1567            (&artifact, artifact_bytes.as_slice()),
1568        ] {
1569            mocks.push(
1570                server
1571                    .mock("PUT", blob_path(digest).as_str())
1572                    .match_header("mise-cache-namespace", "test")
1573                    .match_body(bytes.to_vec())
1574                    .with_status(200)
1575                    .expect(1)
1576                    .create_async()
1577                    .await,
1578            );
1579        }
1580        mocks.push(
1581            server
1582                .mock("PUT", action_path(&result.action).as_str())
1583                .match_header("mise-cache-namespace", "test")
1584                .with_status(200)
1585                .expect(1)
1586                .create_async()
1587                .await,
1588        );
1589        mocks.push(
1590            server
1591                .mock("PUT", action_manifest_path(&selector).as_str())
1592                .match_header("mise-cache-namespace", "test")
1593                .match_header("if-none-match", "*")
1594                .match_body(manifest_bytes.clone())
1595                .with_status(201)
1596                .expect(1)
1597                .create_async()
1598                .await,
1599        );
1600        mocks.push(
1601            server
1602                .mock("GET", action_manifest_path(&selector).as_str())
1603                .with_status(200)
1604                .with_header("etag", &format!("\"{manifest_etag}\""))
1605                .with_body(manifest_bytes.clone())
1606                .expect(1)
1607                .create_async()
1608                .await,
1609        );
1610        mocks.push(
1611            server
1612                .mock("GET", action_path(&action).as_str())
1613                .with_status(200)
1614                .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
1615                .with_body(serde_json::to_vec(&result).unwrap())
1616                .expect(1)
1617                .create_async()
1618                .await,
1619        );
1620        for (digest, bytes) in [
1621            (&action, action_bytes.as_slice()),
1622            (&metadata, metadata_bytes.as_slice()),
1623            (&output_root, directory_bytes.as_slice()),
1624            (&stdout, stdout_bytes.as_slice()),
1625            (&stderr, stderr_bytes.as_slice()),
1626            (&artifact, artifact_bytes.as_slice()),
1627        ] {
1628            mocks.push(
1629                server
1630                    .mock("GET", blob_path(digest).as_str())
1631                    .with_status(200)
1632                    .with_body(bytes)
1633                    .expect(1)
1634                    .create_async()
1635                    .await,
1636            );
1637        }
1638
1639        let writer = remote_agent(
1640            &server,
1641            directory.path().join("writer"),
1642            RemoteCacheMode::WriteOnly,
1643        );
1644        for (index, (digest, bytes)) in [
1645            (&action, action_bytes.as_slice()),
1646            (&metadata, metadata_bytes.as_slice()),
1647            (&output_root, directory_bytes.as_slice()),
1648            (&stdout, stdout_bytes.as_slice()),
1649            (&stderr, stderr_bytes.as_slice()),
1650            (&artifact, artifact_bytes.as_slice()),
1651        ]
1652        .into_iter()
1653        .enumerate()
1654        {
1655            let source = directory.path().join(format!("source-{index}"));
1656            fs::write(&source, bytes).unwrap();
1657            assert!(matches!(
1658                writer
1659                    .respond(AgentRequest::StoreBlob {
1660                        digest: digest.clone(),
1661                        source,
1662                    })
1663                    .await,
1664                AgentResponse::Stored { .. }
1665            ));
1666        }
1667        assert!(matches!(
1668            writer
1669                .respond(AgentRequest::StoreActionResult {
1670                    result: result.clone(),
1671                })
1672                .await,
1673            AgentResponse::ActionStored { .. }
1674        ));
1675        let run = writer.begin_task(&task).await.unwrap();
1676        assert!(matches!(
1677            writer
1678                .respond(AgentRequest::RecordActionPrediction {
1679                    task: run.clone(),
1680                    prediction: prediction.clone(),
1681                })
1682                .await,
1683            AgentResponse::ActionPredictionRecorded
1684        ));
1685        writer.commit_task(&run).await.unwrap();
1686
1687        let reader = remote_agent(
1688            &server,
1689            directory.path().join("reader"),
1690            RemoteCacheMode::ReadOnly,
1691        );
1692        let run = reader.begin_task(&task).await.unwrap();
1693        reader.wait_for_prefetches().await;
1694        assert!(matches!(
1695            reader
1696                .respond(AgentRequest::FindActionPrediction {
1697                    task: run,
1698                    invocation,
1699                })
1700                .await,
1701            AgentResponse::ActionPrediction {
1702                prediction: Some(found)
1703            } if found == prediction
1704        ));
1705        assert!(matches!(
1706            reader
1707                .respond(AgentRequest::FindActionResult {
1708                    action: action.clone(),
1709                })
1710                .await,
1711            AgentResponse::ActionResult {
1712                result: Some(found)
1713            } if found == result
1714        ));
1715        for digest in [&action, &metadata, &output_root] {
1716            assert!(matches!(
1717                reader
1718                    .respond(AgentRequest::FindBlob {
1719                        digest: digest.clone(),
1720                    })
1721                    .await,
1722                AgentResponse::Blob { path: Some(_) }
1723            ));
1724        }
1725        assert!(matches!(
1726            reader
1727                .respond(AgentRequest::RecordActionHit { action })
1728                .await,
1729            AgentResponse::ActionHitRecorded
1730        ));
1731        for mock in mocks {
1732            mock.assert_async().await;
1733        }
1734    }
1735
1736    #[tokio::test]
1737    async fn keeps_newer_local_predictions_when_remote_manifest_is_stale() {
1738        let directory = tempfile::tempdir().unwrap();
1739        let mut server = mockito::Server::new_async().await;
1740        let task = "f".repeat(64);
1741        let invocation = CacheDigest::blake3(b"shared invocation");
1742        let local_prediction = ActionPrediction {
1743            invocation: invocation.clone(),
1744            action: CacheDigest::blake3(b"new local action"),
1745            adapter: "rustc".into(),
1746            payload: "{}".into(),
1747        };
1748        let remote_prediction = ActionPrediction {
1749            invocation: invocation.clone(),
1750            action: CacheDigest::blake3(b"stale remote action"),
1751            adapter: "rustc".into(),
1752            payload: "{}".into(),
1753        };
1754        let remote_manifest = TaskActionManifest {
1755            version: TASK_ACTION_MANIFEST_VERSION,
1756            task: task.clone(),
1757            predictions: vec![remote_prediction],
1758        };
1759        let remote_bytes = canonical_json(&remote_manifest).unwrap();
1760        let remote_etag = blake3::hash(&remote_bytes).to_hex().to_string();
1761        let (_, selector) = CacheAgent::task_manifest_selector(&task).unwrap();
1762        let remote = server
1763            .mock("GET", action_manifest_path(&selector).as_str())
1764            .with_status(200)
1765            .with_header("etag", &format!("\"{remote_etag}\""))
1766            .with_body(remote_bytes)
1767            .expect(1)
1768            .create_async()
1769            .await;
1770
1771        let agent = remote_agent(
1772            &server,
1773            directory.path().join("reader"),
1774            RemoteCacheMode::ReadOnly,
1775        );
1776        agent
1777            .persist_task_manifest(&TaskActionManifest {
1778                version: TASK_ACTION_MANIFEST_VERSION,
1779                task: task.clone(),
1780                predictions: vec![local_prediction.clone()],
1781            })
1782            .unwrap();
1783
1784        let run = agent.begin_task(&task).await.unwrap();
1785        assert!(matches!(
1786            agent
1787                .respond(AgentRequest::FindActionPrediction {
1788                    task: run,
1789                    invocation,
1790                })
1791                .await,
1792            AgentResponse::ActionPrediction {
1793                prediction: Some(found)
1794            } if found == local_prediction
1795        ));
1796        let persisted = agent.load_task_manifest(&task).unwrap().unwrap();
1797        assert_eq!(persisted.predictions, vec![local_prediction]);
1798        remote.assert_async().await;
1799    }
1800
1801    #[tokio::test]
1802    async fn prefetch_does_not_block_task_initialization() {
1803        let directory = tempfile::tempdir().unwrap();
1804        let mut server = mockito::Server::new_async().await;
1805        let task = "9".repeat(64);
1806        let invocation = CacheDigest::blake3(b"prefetched invocation");
1807        let action_bytes = b"prefetched action";
1808        let action = CacheDigest::blake3(action_bytes);
1809        let result = RemoteActionResult {
1810            action: action.clone(),
1811            metadata: None,
1812            output_root: None,
1813            version: 1,
1814        };
1815        let manifest_bytes = canonical_json(&TaskActionManifest {
1816            version: TASK_ACTION_MANIFEST_VERSION,
1817            task: task.clone(),
1818            predictions: vec![ActionPrediction {
1819                invocation,
1820                action: action.clone(),
1821                adapter: "rustc".into(),
1822                payload: "{}".into(),
1823            }],
1824        })
1825        .unwrap();
1826        let manifest_etag = blake3::hash(&manifest_bytes).to_hex().to_string();
1827        let (_, selector) = CacheAgent::task_manifest_selector(&task).unwrap();
1828        let manifest = server
1829            .mock("GET", action_manifest_path(&selector).as_str())
1830            .with_status(200)
1831            .with_header("etag", &format!("\"{manifest_etag}\""))
1832            .with_body(manifest_bytes)
1833            .expect(1)
1834            .create_async()
1835            .await;
1836        let release = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
1837        let response_release = release.clone();
1838        let result_bytes = serde_json::to_vec(&result).unwrap();
1839        let action_result = server
1840            .mock("GET", action_path(&action).as_str())
1841            .with_status(200)
1842            .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
1843            .with_chunked_body(move |writer| {
1844                let (released, condition) = &*response_release;
1845                let mut released = released.lock().unwrap();
1846                while !*released {
1847                    released = condition.wait(released).unwrap();
1848                }
1849                std::io::Write::write_all(writer, &result_bytes)
1850            })
1851            .expect(1)
1852            .create_async()
1853            .await;
1854        let action_blob = server
1855            .mock("GET", blob_path(&action).as_str())
1856            .with_status(200)
1857            .with_body(action_bytes)
1858            .expect(1)
1859            .create_async()
1860            .await;
1861        let agent = remote_agent(
1862            &server,
1863            directory.path().join("reader"),
1864            RemoteCacheMode::ReadOnly,
1865        );
1866
1867        let begin = tokio::time::timeout(Duration::from_secs(2), agent.begin_task(&task)).await;
1868        let (released, condition) = &*release;
1869        *released.lock().unwrap() = true;
1870        condition.notify_all();
1871        let run = begin
1872            .expect("task initialization waited for prefetch")
1873            .unwrap();
1874        assert_eq!(
1875            agent
1876                .task_actions
1877                .lock()
1878                .unwrap()
1879                .get(&run)
1880                .unwrap()
1881                .predictions
1882                .len(),
1883            1
1884        );
1885        agent.wait_for_prefetches().await;
1886        manifest.assert_async().await;
1887        action_result.assert_async().await;
1888        action_blob.assert_async().await;
1889        assert!(agent.actions.find(&action).unwrap().is_some());
1890    }
1891
1892    #[tokio::test]
1893    async fn foreground_action_lookup_does_not_wait_for_prefetch_output() {
1894        let directory = tempfile::tempdir().unwrap();
1895        let mut server = mockito::Server::new_async().await;
1896        let action_bytes = b"prefetched action";
1897        let artifact_bytes = b"prefetched artifact";
1898        let action = CacheDigest::blake3(action_bytes);
1899        let artifact = CacheDigest::blake3(artifact_bytes);
1900        let directory_bytes = canonical_json(&serde_json::json!({
1901            "directories": [],
1902            "files": [{
1903                "digest": artifact,
1904                "executable": false,
1905                "mode": 420,
1906                "name": "artifact",
1907            }],
1908            "symlinks": [],
1909            "version": 1,
1910        }))
1911        .unwrap();
1912        let output_root = CacheDigest::blake3(&directory_bytes);
1913        let result = RemoteActionResult {
1914            action: action.clone(),
1915            metadata: None,
1916            output_root: Some(output_root.clone()),
1917            version: 1,
1918        };
1919        let action_result = server
1920            .mock("GET", action_path(&action).as_str())
1921            .with_status(200)
1922            .with_header("content-type", ACTION_RESULT_MEDIA_TYPE)
1923            .with_body(serde_json::to_vec(&result).unwrap())
1924            .expect(1)
1925            .create_async()
1926            .await;
1927        let action_blob = server
1928            .mock("GET", blob_path(&action).as_str())
1929            .with_status(200)
1930            .with_body(action_bytes)
1931            .expect(1)
1932            .create_async()
1933            .await;
1934        let output_directory = server
1935            .mock("GET", blob_path(&output_root).as_str())
1936            .with_status(200)
1937            .with_body(directory_bytes)
1938            .expect(1)
1939            .create_async()
1940            .await;
1941        let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
1942        let release = Arc::new(std::sync::atomic::AtomicBool::new(false));
1943        let response_started = started.clone();
1944        let response_release = release.clone();
1945        let artifact_blob = server
1946            .mock("GET", blob_path(&artifact).as_str())
1947            .with_status(200)
1948            .with_chunked_body(move |writer| {
1949                response_started.store(true, Ordering::Release);
1950                while !response_release.load(Ordering::Acquire) {
1951                    std::thread::sleep(Duration::from_millis(10));
1952                }
1953                std::io::Write::write_all(writer, artifact_bytes)
1954            })
1955            .expect(1)
1956            .create_async()
1957            .await;
1958        let agent = remote_agent(
1959            &server,
1960            directory.path().join("reader"),
1961            RemoteCacheMode::ReadOnly,
1962        );
1963        let prefetch_agent = agent.clone();
1964        let prefetch_action = action.clone();
1965        let prefetch = tokio::spawn(async move {
1966            prefetch_agent
1967                .prefetch_action(prefetch_action, "rustc".into())
1968                .await
1969        });
1970        tokio::time::timeout(Duration::from_secs(1), async {
1971            while !started.load(Ordering::Acquire) {
1972                tokio::time::sleep(Duration::from_millis(10)).await;
1973            }
1974        })
1975        .await
1976        .expect("prefetch did not request the output blob");
1977
1978        let foreground = tokio::time::timeout(
1979            Duration::from_millis(250),
1980            agent.find_action_result(&action),
1981        )
1982        .await;
1983        release.store(true, Ordering::Release);
1984        prefetch.await.unwrap().unwrap();
1985        let foreground = foreground.expect("foreground action lookup waited for output prefetch");
1986
1987        assert!(matches!(
1988            foreground.unwrap(),
1989            AgentResponse::ActionResult {
1990                result: Some(found)
1991            } if found == result
1992        ));
1993        action_result.assert_async().await;
1994        action_blob.assert_async().await;
1995        output_directory.assert_async().await;
1996        artifact_blob.assert_async().await;
1997    }
1998
1999    #[tokio::test]
2000    async fn session_completion_cancels_outstanding_prefetches() {
2001        let directory = tempfile::tempdir().unwrap();
2002        let agent = CacheAgent::new(directory.path(), "test-version");
2003        let task = tokio::spawn(std::future::pending::<()>());
2004        agent.prefetch_tasks.lock().unwrap().push(task);
2005
2006        tokio::time::timeout(Duration::from_secs(1), agent.cancel_prefetches())
2007            .await
2008            .expect("prefetch cancellation blocked session completion");
2009        assert!(agent.prefetch_tasks.lock().unwrap().is_empty());
2010    }
2011
2012    #[tokio::test]
2013    async fn prefetch_reserves_capacity_for_foreground_transfers() {
2014        let transfers = tokio::sync::Semaphore::new(MAX_REMOTE_TRANSFERS);
2015        let _prefetch = transfers
2016            .acquire_many(MAX_PREFETCH_TRANSFERS as u32)
2017            .await
2018            .unwrap();
2019        assert!(transfers.available_permits() > 0);
2020    }
2021
2022    #[tokio::test]
2023    async fn prefetches_output_files_concurrently() {
2024        let directory = tempfile::tempdir().unwrap();
2025        let (responses, output_root) = output_tree_responses(8);
2026        let (base_url, maximum_in_flight, server) =
2027            delayed_blob_server(responses, Duration::from_millis(50)).await;
2028        let agent = remote_agent_url(
2029            base_url,
2030            directory.path().join("reader"),
2031            RemoteCacheMode::ReadOnly,
2032        );
2033
2034        agent
2035            .prefetch_output_tree(agent.remote.as_deref().unwrap(), &output_root)
2036            .await
2037            .unwrap();
2038        server.await.unwrap();
2039
2040        assert!(maximum_in_flight.load(Ordering::Relaxed) > 1);
2041    }
2042
2043    #[tokio::test]
2044    #[ignore = "local remote-cache throughput benchmark"]
2045    async fn benchmark_prefetch_output_tree_latency() {
2046        let files = std::env::var("MISE_CACHE_BENCH_FILES")
2047            .ok()
2048            .and_then(|value| value.parse().ok())
2049            .unwrap_or(96);
2050        let latency = Duration::from_millis(
2051            std::env::var("MISE_CACHE_BENCH_LATENCY_MS")
2052                .ok()
2053                .and_then(|value| value.parse().ok())
2054                .unwrap_or(100),
2055        );
2056
2057        let directory = tempfile::tempdir().unwrap();
2058        let (responses, output_root) = output_tree_responses(files);
2059        let (base_url, maximum_in_flight, server) = delayed_blob_server(responses, latency).await;
2060        let agent = remote_agent_url(
2061            base_url,
2062            directory.path().join("reader"),
2063            RemoteCacheMode::ReadOnly,
2064        );
2065        let remote = agent.remote.as_deref().unwrap();
2066
2067        let started = std::time::Instant::now();
2068        agent
2069            .prefetch_output_tree(remote, &output_root)
2070            .await
2071            .unwrap();
2072        let elapsed = started.elapsed();
2073
2074        eprintln!(
2075            "prefetched {files} blobs with {} ms latency in {elapsed:?}",
2076            latency.as_millis()
2077        );
2078        server.await.unwrap();
2079        eprintln!(
2080            "maximum concurrent requests: {}",
2081            maximum_in_flight.load(Ordering::Relaxed)
2082        );
2083    }
2084
2085    fn output_tree_responses(files: usize) -> (BTreeMap<String, Vec<u8>>, CacheDigest) {
2086        let mut entries = Vec::with_capacity(files);
2087        let mut responses = BTreeMap::new();
2088        for index in 0..files {
2089            let body = format!("cached artifact {index}").into_bytes();
2090            let digest = CacheDigest::blake3(&body);
2091            entries.push(serde_json::json!({
2092                "digest": digest,
2093                "executable": false,
2094                "mode": 420,
2095                "name": format!("artifact-{index}"),
2096            }));
2097            responses.insert(blob_path(&digest), body);
2098        }
2099        let directory = canonical_json(&serde_json::json!({
2100            "directories": [],
2101            "files": entries,
2102            "symlinks": [],
2103            "version": 1,
2104        }))
2105        .unwrap();
2106        let output_root = CacheDigest::blake3(&directory);
2107        responses.insert(blob_path(&output_root), directory);
2108        (responses, output_root)
2109    }
2110
2111    async fn delayed_blob_server(
2112        responses: BTreeMap<String, Vec<u8>>,
2113        latency: Duration,
2114    ) -> (
2115        url::Url,
2116        Arc<std::sync::atomic::AtomicUsize>,
2117        tokio::task::JoinHandle<()>,
2118    ) {
2119        use std::sync::atomic::AtomicUsize;
2120        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2121
2122        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2123        let address = listener.local_addr().unwrap();
2124        let responses = Arc::new(responses);
2125        let request_count = responses.len();
2126        let in_flight = Arc::new(AtomicUsize::new(0));
2127        let maximum_in_flight = Arc::new(AtomicUsize::new(0));
2128        let observed_maximum = maximum_in_flight.clone();
2129        let server = tokio::spawn(async move {
2130            let mut requests = tokio::task::JoinSet::new();
2131            for _ in 0..request_count {
2132                let (mut socket, _) = listener.accept().await.unwrap();
2133                let responses = responses.clone();
2134                let in_flight = in_flight.clone();
2135                let maximum_in_flight = maximum_in_flight.clone();
2136                requests.spawn(async move {
2137                    let mut request = Vec::new();
2138                    loop {
2139                        let mut chunk = [0; 1024];
2140                        let size = socket.read(&mut chunk).await.unwrap();
2141                        assert!(size > 0, "client closed before sending request headers");
2142                        request.extend_from_slice(&chunk[..size]);
2143                        if request.windows(4).any(|window| window == b"\r\n\r\n") {
2144                            break;
2145                        }
2146                    }
2147                    let request = String::from_utf8_lossy(&request);
2148                    let path = request
2149                        .lines()
2150                        .next()
2151                        .and_then(|line| line.split_whitespace().nth(1))
2152                        .unwrap();
2153                    let body = responses.get(path).unwrap();
2154                    let active = in_flight.fetch_add(1, Ordering::Relaxed) + 1;
2155                    maximum_in_flight.fetch_max(active, Ordering::Relaxed);
2156                    tokio::time::sleep(latency).await;
2157                    socket
2158                        .write_all(
2159                            format!(
2160                                "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
2161                                body.len()
2162                            )
2163                            .as_bytes(),
2164                        )
2165                        .await
2166                        .unwrap();
2167                    socket.write_all(body).await.unwrap();
2168                    in_flight.fetch_sub(1, Ordering::Relaxed);
2169                });
2170            }
2171            while requests.join_next().await.is_some() {}
2172        });
2173        (
2174            format!("http://{address}").parse().unwrap(),
2175            observed_maximum,
2176            server,
2177        )
2178    }
2179
2180    fn remote_agent(
2181        server: &mockito::ServerGuard,
2182        cache_dir: PathBuf,
2183        mode: RemoteCacheMode,
2184    ) -> CacheAgent {
2185        remote_agent_url(server.url().parse().unwrap(), cache_dir, mode)
2186    }
2187
2188    fn remote_agent_url(
2189        base_url: url::Url,
2190        cache_dir: PathBuf,
2191        mode: RemoteCacheMode,
2192    ) -> CacheAgent {
2193        let client = RemoteCacheClient::new(crate::RemoteCacheConfig {
2194            base_url,
2195            namespace: "test".into(),
2196            token: None,
2197            token_file: None,
2198            oidc_audience: None,
2199            connect_timeout: Duration::from_secs(1),
2200            read_timeout: Duration::from_secs(1),
2201            download_timeout: Duration::from_secs(1),
2202            retries: 0,
2203        })
2204        .unwrap();
2205        CacheAgent::new_remote(
2206            &cache_dir,
2207            "test-version",
2208            AgentRemoteCache {
2209                client,
2210                mode,
2211                staging_dir: cache_dir.join("remote"),
2212            },
2213        )
2214    }
2215
2216    fn blob_path(digest: &CacheDigest) -> String {
2217        format!(
2218            "/v1/blobs/{}/{}/{}",
2219            digest.algorithm, digest.hash, digest.size
2220        )
2221    }
2222
2223    fn action_path(digest: &CacheDigest) -> String {
2224        format!(
2225            "/v1/action-results/{}/{}/{}",
2226            digest.algorithm, digest.hash, digest.size
2227        )
2228    }
2229
2230    fn action_manifest_path(digest: &CacheDigest) -> String {
2231        format!(
2232            "/v1/action-manifests/{}/{}/{}",
2233            digest.algorithm, digest.hash, digest.size
2234        )
2235    }
2236
2237    #[tokio::test]
2238    async fn merges_overlapping_runs_into_one_task_manifest() {
2239        let directory = tempfile::tempdir().unwrap();
2240        let cache = directory.path().join("cache");
2241        let task = "d".repeat(64);
2242        let agent = CacheAgent::new(&cache, "test-version");
2243        let first_run = agent.begin_task(&task).await.unwrap();
2244        let second_run = agent.begin_task(&task).await.unwrap();
2245        assert_ne!(first_run, second_run);
2246        let first_invocation = CacheDigest::blake3(b"overlap one");
2247        let second_invocation = CacheDigest::blake3(b"overlap two");
2248        for (run, invocation) in [
2249            (&first_run, &first_invocation),
2250            (&second_run, &second_invocation),
2251        ] {
2252            assert!(matches!(
2253                agent
2254                    .respond(AgentRequest::RecordActionPrediction {
2255                        task: run.clone(),
2256                        prediction: ActionPrediction {
2257                            invocation: invocation.clone(),
2258                            action: CacheDigest::blake3(invocation.hash.as_bytes()),
2259                            adapter: "rustc".into(),
2260                            payload: "{}".into(),
2261                        },
2262                    })
2263                    .await,
2264                AgentResponse::ActionPredictionRecorded
2265            ));
2266        }
2267        agent.commit_task(&first_run).await.unwrap();
2268        agent.commit_task(&second_run).await.unwrap();
2269
2270        let next = CacheAgent::new(cache, "test-version");
2271        let run = next.begin_task(&task).await.unwrap();
2272        for invocation in [first_invocation, second_invocation] {
2273            assert!(matches!(
2274                next.respond(AgentRequest::FindActionPrediction {
2275                    task: run.clone(),
2276                    invocation,
2277                })
2278                .await,
2279                AgentResponse::ActionPrediction {
2280                    prediction: Some(_)
2281                }
2282            ));
2283        }
2284    }
2285
2286    #[test]
2287    fn keeps_local_manifest_when_remote_merge_exceeds_prediction_limit() {
2288        let task = "7".repeat(64);
2289        let prediction = |index: usize| {
2290            let digest = CacheDigest::blake3(&index.to_le_bytes());
2291            ActionPrediction {
2292                invocation: digest.clone(),
2293                action: digest,
2294                adapter: "rustc".into(),
2295                payload: "{}".into(),
2296            }
2297        };
2298        let local = TaskActionManifest {
2299            version: TASK_ACTION_MANIFEST_VERSION,
2300            task: task.clone(),
2301            predictions: (0..MAX_TASK_ACTION_PREDICTIONS).map(prediction).collect(),
2302        };
2303        let expected_first = local.predictions[0].clone();
2304        let remote = TaskActionManifest {
2305            version: TASK_ACTION_MANIFEST_VERSION,
2306            task: task.clone(),
2307            predictions: vec![prediction(MAX_TASK_ACTION_PREDICTIONS)],
2308        };
2309
2310        let (manifest, merged) = merge_remote_task_manifest(&task, remote, local);
2311        assert!(!merged);
2312        assert_eq!(manifest.predictions.len(), MAX_TASK_ACTION_PREDICTIONS);
2313        assert_eq!(manifest.predictions[0], expected_first);
2314    }
2315
2316    #[test]
2317    fn task_manifest_lock_is_shared_across_agents() {
2318        let directory = tempfile::tempdir().unwrap();
2319        let cache = directory.path().join("cache");
2320        let first = CacheAgent::new(&cache, "test-version");
2321        let second = CacheAgent::new(&cache, "test-version");
2322        let task = "8".repeat(64);
2323
2324        let first_lock = first.lock_task_manifest(&task).unwrap();
2325        let mut contender = fslock::LockFile::open(&second.task_manifest_lock_path(&task)).unwrap();
2326        assert!(!contender.try_lock().unwrap());
2327        drop(first_lock);
2328        assert!(contender.try_lock().unwrap());
2329    }
2330
2331    #[tokio::test]
2332    async fn memoizes_client_observed_executable_identities() {
2333        let directory = tempfile::tempdir().unwrap();
2334        let agent = CacheAgent::new(directory.path(), "test-version");
2335        let executable = directory.path().join("rustc");
2336        let environment = BTreeMap::from([("RUSTUP_TOOLCHAIN".into(), Some("stable".into()))]);
2337
2338        let response = agent
2339            .respond(AgentRequest::FindExecutableIdentity {
2340                executable: executable.clone(),
2341                environment: environment.clone(),
2342            })
2343            .await;
2344        assert!(matches!(
2345            response,
2346            AgentResponse::ExecutableIdentity { stdout: None }
2347        ));
2348
2349        let response = agent
2350            .respond(AgentRequest::StoreExecutableIdentity {
2351                executable: executable.clone(),
2352                environment: environment.clone(),
2353                stdout: b"rustc identity".to_vec(),
2354            })
2355            .await;
2356        assert!(matches!(
2357            response,
2358            AgentResponse::ExecutableIdentity {
2359                stdout: Some(stdout)
2360            } if stdout == b"rustc identity"
2361        ));
2362
2363        let response = agent
2364            .respond(AgentRequest::FindExecutableIdentity {
2365                executable,
2366                environment,
2367            })
2368            .await;
2369        assert!(matches!(
2370            response,
2371            AgentResponse::ExecutableIdentity {
2372                stdout: Some(stdout)
2373            } if stdout == b"rustc identity"
2374        ));
2375    }
2376
2377    #[test]
2378    fn bounds_executable_identity_entry_count() {
2379        let directory = tempfile::tempdir().unwrap();
2380        let agent = CacheAgent::new(directory.path(), "test-version");
2381        for index in 0..MAX_EXECUTABLE_IDENTITIES {
2382            agent
2383                .store_executable_identity(
2384                    directory.path().join(format!("rustc-{index}")),
2385                    BTreeMap::new(),
2386                    vec![b'x'],
2387                )
2388                .unwrap();
2389        }
2390
2391        assert!(
2392            agent
2393                .store_executable_identity(
2394                    directory.path().join("one-too-many"),
2395                    BTreeMap::new(),
2396                    vec![b'x'],
2397                )
2398                .is_err()
2399        );
2400    }
2401
2402    #[test]
2403    fn bounds_executable_identity_retained_bytes() {
2404        let directory = tempfile::tempdir().unwrap();
2405        let agent = CacheAgent::new(directory.path(), "test-version");
2406        for index in 0..MAX_EXECUTABLE_IDENTITY_BYTES / MAX_EXECUTABLE_IDENTITY_SIZE {
2407            agent
2408                .store_executable_identity(
2409                    directory.path().join(format!("rustc-{index}")),
2410                    BTreeMap::new(),
2411                    vec![b'x'; MAX_EXECUTABLE_IDENTITY_SIZE],
2412                )
2413                .unwrap();
2414        }
2415
2416        assert!(
2417            agent
2418                .store_executable_identity(
2419                    directory.path().join("one-byte-too-many"),
2420                    BTreeMap::new(),
2421                    vec![b'x'],
2422                )
2423                .is_err()
2424        );
2425    }
2426
2427    #[tokio::test]
2428    async fn version_skew_is_a_handshake_miss() {
2429        let directory = tempfile::tempdir().unwrap();
2430        let agent = CacheAgent::new(directory.path(), "agent-version");
2431        let (mut client, server) = tokio::io::duplex(1024);
2432        let task = tokio::spawn(async move { agent.handle_connection(server).await });
2433        let request = AgentRequest::Hello {
2434            protocol: AGENT_PROTOCOL_VERSION,
2435            client_version: "other-version".into(),
2436        };
2437        let mut encoded = serde_json::to_vec(&request).unwrap();
2438        encoded.push(b'\n');
2439        client.write_all(&encoded).await.unwrap();
2440        let mut response = String::new();
2441        BufReader::new(&mut client)
2442            .read_line(&mut response)
2443            .await
2444            .unwrap();
2445
2446        assert!(matches!(
2447            serde_json::from_str(&response).unwrap(),
2448            AgentResponse::Error { .. }
2449        ));
2450        task.await.unwrap().unwrap();
2451    }
2452}