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