Skip to main content

mbx_cache_core/
agent.rs

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