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