Skip to main content

ic_testkit/artifacts/
wasm_cache.rs

1use serde_json::Value;
2use std::{
3    collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
4    ffi::{OsStr, OsString},
5    fs::{self, File},
6    io,
7    path::{Path, PathBuf},
8    process::{Child, Command, ExitStatus, Output, Stdio},
9    sync::{
10        Arc, RwLock,
11        atomic::{AtomicUsize, Ordering},
12        mpsc::{self, RecvTimeoutError},
13    },
14    thread,
15    time::{Duration, Instant, SystemTime},
16};
17use toml::Value as TomlValue;
18
19use crate::timing::saturating_add_optional_duration;
20
21use super::{
22    cache_fs::{
23        ArtifactCacheMaintenance, ArtifactCachePrunePolicy, ArtifactCachePruneReport, CacheFsError,
24        RetainedCacheEntry, cache_entry_last_used, cache_maintenance_due, directory_logical_size,
25        ensure_cache_directory_tag as ensure_cache_tag, is_sha256_directory, lock_cache_file,
26        lock_cache_file_with_wait_observer, perform_scheduled_cache_maintenance,
27        prune_direct_child_directories, record_cache_entry_use as record_entry_use,
28        record_cache_maintenance, remove_path_if_present, remove_unretained_entry,
29    },
30    digest::{
31        InputDigest, InputHasher, LabeledPathDigestCache, copy_file_atomic, digest_bytes,
32        digest_file, digest_labeled_paths_composable, os_bytes, write_atomic,
33    },
34    wasm::wasm_path,
35};
36
37const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
38const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
39const AUTOMATIC_ENVIRONMENT: &[&str] = &[
40    "CARGO_BUILD_RUSTC",
41    "CARGO_ENCODED_RUSTFLAGS",
42    "RUSTC",
43    "RUSTC_WRAPPER",
44    "RUSTC_WORKSPACE_WRAPPER",
45    "RUSTFLAGS",
46    "RUSTUP_TOOLCHAIN",
47];
48
49/// Complete caller-owned description of one cacheable Cargo Wasm build.
50///
51/// The selected package graph, sources, semantic workspace projection, Cargo
52/// configuration, Rust toolchain files, target, profile arguments, explicit
53/// child environment, selected inherited environment, and additional watched
54/// inputs contribute to the build fingerprint. The complete workspace
55/// manifest and lockfile remain conservative mutation-validation inputs.
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct WasmBuildSpec {
58    workspace_root: PathBuf,
59    target_dir: PathBuf,
60    packages: Vec<String>,
61    profile_target_dir: String,
62    cargo_profile_args: Vec<OsString>,
63    extra_env: BTreeMap<OsString, OsString>,
64    inherited_env: BTreeSet<OsString>,
65    additional_inputs: Vec<PathBuf>,
66    target: String,
67    cargo_program: OsString,
68    rustc_program: OsString,
69    cache_mode: WasmBuildCacheMode,
70    prune_policy: Option<ArtifactCachePrunePolicy>,
71    prune_interval: Option<Duration>,
72    shared_incremental_maintenance_config: Option<SharedIncrementalTargetMaintenanceConfig>,
73}
74
75/// Failure handling for integrated shared incremental-target maintenance.
76#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
77pub enum SharedIncrementalTargetMaintenanceFailureMode {
78    /// Fail the Wasm acquisition when scheduled maintenance fails.
79    #[default]
80    Strict,
81    /// Preserve the acquisition and attach a structured failed-maintenance outcome.
82    BestEffort,
83}
84
85/// Scheduled shared incremental-target maintenance attached to a Wasm acquisition.
86#[derive(Clone, Copy, Debug, Eq, PartialEq)]
87pub struct SharedIncrementalTargetMaintenanceConfig {
88    policy: SharedIncrementalTargetPrunePolicy,
89    minimum_interval: Duration,
90    failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
91}
92
93/// Cargo-target ownership mode for one exact cached Wasm build.
94#[non_exhaustive]
95#[derive(Clone, Debug, Eq, PartialEq)]
96pub enum WasmBuildCacheMode {
97    /// Build each exact fingerprint in its own content-addressed Cargo target.
98    Isolated,
99    /// Build misses in caller-owned shared Cargo incremental state, then cache final Wasm files.
100    SharedIncremental {
101        /// Mutable Cargo target directory shared across source fingerprints.
102        target_dir: PathBuf,
103    },
104}
105
106/// Whether a cacheable Wasm build ran Cargo or reused exact matching artifacts.
107#[derive(Clone, Debug, Eq, PartialEq)]
108pub enum WasmBuildOutcome {
109    /// Cargo ran and a new successful stamp was published.
110    Built(WasmBuildRecord),
111    /// Existing artifacts and their content-addressed stamp matched exactly.
112    Reused(WasmBuildRecord),
113}
114
115/// Details shared by built and reused Wasm outcomes.
116#[derive(Clone, Debug, Eq, PartialEq)]
117pub struct WasmBuildRecord {
118    fingerprint: InputDigest,
119    input_digest: InputDigest,
120    exact_cache_path: PathBuf,
121    _retention: RetainedCacheEntry,
122    artifacts: Vec<PathBuf>,
123    timings: WasmBuildTimings,
124    maintenance: Option<ArtifactCacheMaintenance>,
125    shared_incremental_maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
126}
127
128/// Timings for cache coordination, input resolution, and Cargo execution.
129#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
130pub struct WasmBuildTimings {
131    lock_wait: Duration,
132    shared_incremental_lock_wait: Option<Duration>,
133    input_resolution: WasmInputResolutionTimings,
134    cargo_build: Option<Duration>,
135    cache_maintenance: Option<Duration>,
136    total: Duration,
137}
138
139/// Detailed timings for exact Wasm build-input resolution.
140#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
141pub struct WasmInputResolutionTimings {
142    tool_identity: Duration,
143    cargo_metadata: Duration,
144    input_discovery: Duration,
145    content_hashing: Duration,
146    total: Duration,
147}
148
149/// Primary phase in which one cacheable Wasm acquisition failed.
150#[non_exhaustive]
151#[derive(Clone, Copy, Debug, Eq, PartialEq)]
152pub enum WasmBuildFailurePhase {
153    /// The caller supplied an invalid build specification.
154    Specification,
155    /// Waiting for the exact-cache lock or preparing its directory.
156    ExactCacheCoordination,
157    /// Reading Cargo or rustc identity.
158    ToolIdentity,
159    /// Running or decoding Cargo metadata.
160    CargoMetadata,
161    /// Discovering selected source and configuration inputs.
162    InputDiscovery,
163    /// Hashing selected and conservative input contents.
164    ContentHashing,
165    /// Waiting for or preparing a shared incremental target.
166    SharedTargetCoordination,
167    /// Applying configured shared-target maintenance.
168    SharedTargetMaintenance,
169    /// Executing Cargo for the selected Wasm packages.
170    CargoBuild,
171    /// Validating, copying, stamping, or materializing Wasm artifacts.
172    ArtifactPublication,
173    /// Applying exact-cache retention after a successful acquisition.
174    ExactCacheMaintenance,
175    /// Removing an incomplete exact-cache entry after failure.
176    Cleanup,
177}
178
179/// Partial phase timings retained when a Wasm acquisition fails.
180#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
181pub struct WasmBuildFailureTimings {
182    exact_cache_coordination: Duration,
183    shared_target_coordination: Option<Duration>,
184    input_resolution: WasmInputResolutionTimings,
185    shared_target_maintenance: Option<Duration>,
186    cargo_build: Option<Duration>,
187    artifact_publication: Option<Duration>,
188    exact_cache_maintenance: Option<Duration>,
189    cleanup: Option<Duration>,
190    total: Duration,
191}
192
193/// One exact local Cargo source or configuration input under a stable logical label.
194#[derive(Clone, Debug, Eq, PartialEq)]
195pub struct CargoBuildInput {
196    label: PathBuf,
197    path: PathBuf,
198}
199
200/// Resolved exact inputs and identity for one [`WasmBuildSpec`].
201///
202/// The snapshot can be resolved again after an external operation to detect
203/// source, configuration, toolchain, argument, or environment changes.
204#[derive(Clone, Debug, Eq, PartialEq)]
205pub struct ResolvedCargoBuildInputs {
206    fingerprint: InputDigest,
207    input_digest: InputDigest,
208    validation_digest: InputDigest,
209    inputs: Vec<CargoBuildInput>,
210    exclusions: Vec<PathBuf>,
211    timings: WasmInputResolutionTimings,
212}
213
214pub(super) struct WasmBuildBatchInputResolver<'a, 'session> {
215    specs: &'a [WasmBuildSpec],
216    groups: Vec<BatchResolutionGroup>,
217    group_by_index: Vec<usize>,
218    resolved: Vec<Option<Result<ResolvedCargoBuildInputs, WasmBuildError>>>,
219    session: Option<&'session mut WasmBuildSessionState>,
220    snapshot: Option<&'session WasmBuildInputSnapshotState>,
221    metrics: WasmBuildBatchInputMetrics,
222}
223
224pub(super) struct WasmBuildSessionState {
225    snapshots: Vec<(WasmBuildSpec, ResolvedCargoBuildInputs)>,
226    digest_cache: LabeledPathDigestCache,
227    snapshot_reuses: usize,
228    invalidated: bool,
229}
230
231pub(super) struct WasmBuildInputSnapshotState {
232    snapshots: Vec<(WasmBuildSpec, ResolvedCargoBuildInputs)>,
233    preparation_metrics: WasmBuildBatchInputMetrics,
234    preparation_timings: WasmInputResolutionTimings,
235    reader_reuses: AtomicUsize,
236    invalidation: Arc<RwLock<bool>>,
237}
238
239pub(super) struct WasmBuildBatchAttempt {
240    pub(super) result: Result<WasmBuildOutcome, WasmBuildError>,
241    pub(super) failure_phase: Option<WasmBuildFailurePhase>,
242    pub(super) failure_timings: Option<WasmBuildFailureTimings>,
243}
244
245impl WasmBuildBatchAttempt {
246    pub(super) fn invalid_spec(error: WasmBuildError, total: Duration) -> Self {
247        Self {
248            result: Err(error),
249            failure_phase: Some(WasmBuildFailurePhase::Specification),
250            failure_timings: Some(WasmBuildFailureTimings {
251                total,
252                ..WasmBuildFailureTimings::default()
253            }),
254        }
255    }
256}
257
258struct BatchResolutionGroup {
259    indexes: Vec<usize>,
260}
261
262struct ResolvedLocalInputs {
263    validation_inputs: Vec<(PathBuf, PathBuf)>,
264    fingerprint: LocalInputFingerprint,
265}
266
267enum LocalInputFingerprint {
268    Conservative,
269    Projected {
270        inputs: Vec<(PathBuf, PathBuf)>,
271        workspace: InputDigest,
272    },
273}
274
275#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
276pub(super) struct WasmBuildBatchInputMetrics {
277    pub(super) runs: usize,
278    pub(super) reuses: usize,
279    pub(super) session_reuses: usize,
280    pub(super) prepared_reuses: usize,
281}
282
283#[derive(Eq, PartialEq)]
284struct BatchResolutionKey {
285    workspace_root: PathBuf,
286    cargo_program: OsString,
287    rustc_program: OsString,
288    metadata_arguments: Vec<OsString>,
289    environment: BTreeMap<OsString, Option<OsString>>,
290}
291
292/// Lock-coordinated disk-usage observation for a caller-owned shared Cargo target.
293#[derive(Clone, Debug, Eq, PartialEq)]
294pub struct SharedIncrementalTargetInspection {
295    target_dir: PathBuf,
296    logical_size_bytes: u64,
297    last_used: SystemTime,
298    lock_wait: Duration,
299}
300
301/// Whole-target retention limits for caller-owned shared Cargo state.
302///
303/// Unlike immutable fingerprint entries, a shared Cargo target has no safe
304/// per-entry LRU boundary. When either configured limit is exceeded,
305/// maintenance clears every other target child while preserving
306/// `ic-testkit`'s coordination metadata and the target root. Callers must not
307/// colocate unrelated data that needs to survive a clear.
308#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
309pub struct SharedIncrementalTargetPrunePolicy {
310    max_age: Option<Duration>,
311    max_size_bytes: Option<u64>,
312}
313
314/// Result of explicit shared Cargo target maintenance.
315#[derive(Clone, Debug, Eq, PartialEq)]
316pub struct SharedIncrementalTargetMaintenance {
317    target_dir: PathBuf,
318    logical_size_bytes_before: u64,
319    logical_size_bytes_after: u64,
320    last_used_before: SystemTime,
321    cleared: bool,
322    lock_wait: Duration,
323    maintenance: Duration,
324}
325
326/// Result of interval-limited shared Cargo target maintenance.
327#[non_exhaustive]
328#[derive(Clone, Debug, Eq, PartialEq)]
329pub enum SharedIncrementalTargetMaintenanceOutcome {
330    /// The configured shared target does not exist, so nothing was created or inspected.
331    Missing {
332        /// Configured target path. A missing path cannot necessarily be canonicalized.
333        target_dir: PathBuf,
334    },
335    /// A successful matching maintenance pass is still inside the requested interval.
336    Skipped {
337        /// Canonical shared Cargo target directory.
338        target_dir: PathBuf,
339        /// Time spent waiting for another process using the shared target.
340        lock_wait: Duration,
341        /// Time spent checking the small cross-process schedule marker.
342        schedule_check: Duration,
343    },
344    /// Retention was evaluated under the shared-target lock.
345    Performed {
346        /// Completed retention report.
347        maintenance: SharedIncrementalTargetMaintenance,
348        /// Time spent checking the small cross-process schedule marker.
349        schedule_check: Duration,
350    },
351    /// Integrated best-effort maintenance failed without invalidating the Wasm acquisition.
352    Failed {
353        /// Canonical shared Cargo target directory.
354        target_dir: PathBuf,
355        /// Time spent waiting for another process using the shared target.
356        lock_wait: Duration,
357        /// Rendered maintenance failure retained for diagnostics.
358        message: String,
359    },
360}
361
362/// Observation settings for one cacheable Wasm build.
363#[derive(Clone, Copy, Debug, Eq, PartialEq)]
364pub struct WasmBuildProgressConfig {
365    heartbeat_interval: Option<Duration>,
366    emit_cargo_output: bool,
367}
368
369/// Raw child-process stream attached to a Cargo progress event.
370#[derive(Clone, Copy, Debug, Eq, PartialEq)]
371pub enum WasmBuildOutputStream {
372    /// Cargo standard output.
373    Stdout,
374    /// Cargo standard error.
375    Stderr,
376}
377
378/// Final cache state reported by a successful observed build.
379#[derive(Clone, Copy, Debug, Eq, PartialEq)]
380pub enum WasmBuildProgressOutcome {
381    /// Cargo ran and exact artifacts were published.
382    Built,
383    /// Exact artifacts were reused without Cargo.
384    Reused,
385}
386
387/// Potentially long phase of one observed Wasm-cache acquisition.
388#[non_exhaustive]
389#[derive(Clone, Copy, Debug, Eq, PartialEq)]
390pub enum WasmBuildProgressPhase {
391    /// Waiting for exclusive ownership of the exact artifact cache.
392    ExactCacheLock,
393    /// Reading the Cargo executable identity.
394    CargoIdentity,
395    /// Reading the Rust compiler identity.
396    RustcIdentity,
397    /// Resolving Cargo's package graph.
398    CargoMetadata,
399    /// Discovering local source and configuration inputs.
400    InputDiscovery,
401    /// Hashing exact source and configuration contents.
402    ContentHashing,
403    /// Waiting for exclusive ownership of a shared incremental Cargo target.
404    SharedTargetLock,
405    /// Inspecting or clearing a shared incremental Cargo target.
406    SharedTargetMaintenance,
407    /// Compiling the selected Wasm packages.
408    CargoBuild,
409    /// Validating, copying, hashing, or stamping exact artifacts.
410    ArtifactPublication,
411    /// Applying retention to immutable exact-cache entries.
412    ExactCacheMaintenance,
413}
414
415/// Structured progress emitted by an observed cacheable Wasm build.
416#[non_exhaustive]
417#[derive(Clone, Debug, Eq, PartialEq)]
418pub enum WasmBuildProgressEvent {
419    /// One build/cache acquisition started.
420    Started,
421    /// One exact Cargo input-resolution pass completed.
422    InputsResolved {
423        /// Complete exact build fingerprint.
424        fingerprint: InputDigest,
425        /// Semantic selected-source/configuration digest.
426        input_digest: InputDigest,
427        /// Time spent on this resolution pass.
428        elapsed: Duration,
429    },
430    /// No reusable exact entry existed for this fingerprint.
431    CacheMiss {
432        /// Missing exact fingerprint.
433        fingerprint: InputDigest,
434    },
435    /// Exact artifacts were found and materialized when necessary.
436    CacheHit {
437        /// Reused exact fingerprint.
438        fingerprint: InputDigest,
439    },
440    /// The build is about to wait for a caller-owned shared Cargo target.
441    SharedTargetLockStarted {
442        /// Shared target selected by the build specification.
443        target_dir: PathBuf,
444    },
445    /// Exclusive shared-target ownership was acquired.
446    SharedTargetLockAcquired {
447        /// Canonical shared target directory.
448        target_dir: PathBuf,
449        /// Time spent waiting for another process.
450        wait: Duration,
451    },
452    /// Scheduled shared-target retention is about to be evaluated under lock.
453    SharedTargetMaintenanceStarted {
454        /// Canonical shared target selected by the build specification.
455        target_dir: PathBuf,
456    },
457    /// Scheduled shared-target retention completed or was skipped.
458    SharedTargetMaintenanceFinished {
459        /// Structured retention result attached to the successful acquisition.
460        outcome: SharedIncrementalTargetMaintenanceOutcome,
461    },
462    /// Cargo compilation started.
463    CargoStarted {
464        /// Cargo target receiving compilation state.
465        target_dir: PathBuf,
466    },
467    /// One raw Cargo output chunk was read without lossy UTF-8 conversion.
468    CargoOutput {
469        /// Child-process stream that produced the bytes.
470        stream: WasmBuildOutputStream,
471        /// Raw output bytes in per-stream read order.
472        bytes: Vec<u8>,
473    },
474    /// The current acquisition phase remained active without another event.
475    Heartbeat {
476        /// Phase that is still making or waiting for progress.
477        phase: WasmBuildProgressPhase,
478        /// Time elapsed since this phase started.
479        elapsed: Duration,
480    },
481    /// Cargo exited and all captured output was drained.
482    CargoFinished {
483        /// Whether Cargo reported success.
484        success: bool,
485        /// Portable exit code when the platform exposes one.
486        code: Option<i32>,
487        /// Complete Cargo execution duration.
488        elapsed: Duration,
489    },
490    /// The complete cacheable build operation succeeded.
491    Finished {
492        /// Whether Cargo ran or an exact entry was reused.
493        outcome: WasmBuildProgressOutcome,
494        /// Exact fingerprint selected by the operation.
495        fingerprint: InputDigest,
496        /// Total operation duration.
497        elapsed: Duration,
498    },
499}
500
501impl Default for WasmBuildProgressConfig {
502    fn default() -> Self {
503        Self {
504            heartbeat_interval: Some(Duration::from_secs(10)),
505            emit_cargo_output: true,
506        }
507    }
508}
509
510impl WasmBuildProgressConfig {
511    /// Observe acquisition progress and emit a heartbeat at least every ten quiet seconds.
512    #[must_use]
513    pub fn new() -> Self {
514        Self::default()
515    }
516
517    /// Select the maximum quiet interval between phase-aware heartbeat events.
518    ///
519    /// A zero interval is rejected before any build work begins.
520    #[must_use]
521    pub const fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
522        self.heartbeat_interval = Some(interval);
523        self
524    }
525
526    /// Disable time-based heartbeats while retaining phase and output events.
527    #[must_use]
528    pub const fn without_heartbeats(mut self) -> Self {
529        self.heartbeat_interval = None;
530        self
531    }
532
533    /// Select whether raw Cargo stdout/stderr chunks are forwarded.
534    ///
535    /// Output is always captured for structured build failures.
536    #[must_use]
537    pub const fn with_cargo_output(mut self, emit: bool) -> Self {
538        self.emit_cargo_output = emit;
539        self
540    }
541
542    /// Configured heartbeat interval, or `None` when disabled.
543    #[must_use]
544    pub const fn heartbeat_interval(self) -> Option<Duration> {
545        self.heartbeat_interval
546    }
547
548    /// Whether raw Cargo output chunks are emitted to the observer.
549    #[must_use]
550    pub const fn emits_cargo_output(self) -> bool {
551        self.emit_cargo_output
552    }
553}
554
555struct ProgressReporter<'a> {
556    config: WasmBuildProgressConfig,
557    observer: Option<&'a mut dyn FnMut(WasmBuildProgressEvent)>,
558    last_event: Instant,
559    failure_phase: Option<WasmBuildFailurePhase>,
560    failure_timings: WasmBuildFailureTimings,
561}
562
563impl ProgressReporter<'_> {
564    fn silent() -> Self {
565        Self {
566            config: WasmBuildProgressConfig {
567                heartbeat_interval: None,
568                emit_cargo_output: false,
569            },
570            observer: None,
571            last_event: Instant::now(),
572            failure_phase: None,
573            failure_timings: WasmBuildFailureTimings::default(),
574        }
575    }
576
577    fn observed(
578        config: WasmBuildProgressConfig,
579        observer: &'_ mut dyn FnMut(WasmBuildProgressEvent),
580    ) -> ProgressReporter<'_> {
581        ProgressReporter {
582            config,
583            observer: Some(observer),
584            last_event: Instant::now(),
585            failure_phase: None,
586            failure_timings: WasmBuildFailureTimings::default(),
587        }
588    }
589
590    fn emit(&mut self, event: WasmBuildProgressEvent) {
591        if let Some(observer) = &mut self.observer {
592            observer(event);
593            self.last_event = Instant::now();
594        }
595    }
596
597    const fn is_observed(&self) -> bool {
598        self.observer.is_some()
599    }
600
601    fn heartbeat_due_in(&self) -> Option<Duration> {
602        self.config
603            .heartbeat_interval
604            .map(|interval| interval.saturating_sub(self.last_event.elapsed()))
605    }
606
607    fn emit_heartbeat(&mut self, phase: WasmBuildProgressPhase, elapsed: Duration) {
608        self.emit(WasmBuildProgressEvent::Heartbeat { phase, elapsed });
609    }
610
611    fn emit_heartbeat_if_due(&mut self, phase: WasmBuildProgressPhase, elapsed: Duration) {
612        if self.heartbeat_due_in() == Some(Duration::ZERO) {
613            self.emit_heartbeat(phase, elapsed);
614        }
615    }
616
617    fn run_phase<T, F>(&mut self, phase: WasmBuildProgressPhase, operation: F) -> T
618    where
619        T: Send,
620        F: FnOnce() -> T + Send,
621    {
622        let started = Instant::now();
623        self.begin_phase(progress_failure_phase(phase));
624        let result = if !self.is_observed() || self.config.heartbeat_interval.is_none() {
625            operation()
626        } else {
627            thread::scope(|scope| {
628                let (finished, completion) = mpsc::sync_channel(0);
629                let worker = scope.spawn(move || {
630                    let result = operation();
631                    let _ = finished.send(());
632                    result
633                });
634                loop {
635                    let wait = self
636                        .heartbeat_due_in()
637                        .expect("observed phase must have a heartbeat interval");
638                    match completion.recv_timeout(wait) {
639                        Ok(()) | Err(RecvTimeoutError::Disconnected) => {
640                            return worker
641                                .join()
642                                .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
643                        }
644                        Err(RecvTimeoutError::Timeout) => {
645                            self.emit_heartbeat(phase, started.elapsed());
646                        }
647                    }
648                }
649            })
650        };
651        self.record_phase(progress_failure_phase(phase), started.elapsed());
652        result
653    }
654
655    const fn begin_phase(&mut self, phase: WasmBuildFailurePhase) {
656        self.failure_phase = Some(phase);
657    }
658
659    fn record_phase(&mut self, phase: WasmBuildFailurePhase, elapsed: Duration) {
660        self.failure_phase = Some(phase);
661        let timings = &mut self.failure_timings;
662        match phase {
663            WasmBuildFailurePhase::Specification => {}
664            WasmBuildFailurePhase::ExactCacheCoordination => {
665                timings.exact_cache_coordination =
666                    timings.exact_cache_coordination.saturating_add(elapsed);
667            }
668            WasmBuildFailurePhase::ToolIdentity => {
669                timings.input_resolution.tool_identity = timings
670                    .input_resolution
671                    .tool_identity
672                    .saturating_add(elapsed);
673                timings.input_resolution.total =
674                    timings.input_resolution.total.saturating_add(elapsed);
675            }
676            WasmBuildFailurePhase::CargoMetadata => {
677                timings.input_resolution.cargo_metadata = timings
678                    .input_resolution
679                    .cargo_metadata
680                    .saturating_add(elapsed);
681                timings.input_resolution.total =
682                    timings.input_resolution.total.saturating_add(elapsed);
683            }
684            WasmBuildFailurePhase::InputDiscovery => {
685                timings.input_resolution.input_discovery = timings
686                    .input_resolution
687                    .input_discovery
688                    .saturating_add(elapsed);
689                timings.input_resolution.total =
690                    timings.input_resolution.total.saturating_add(elapsed);
691            }
692            WasmBuildFailurePhase::ContentHashing => {
693                timings.input_resolution.content_hashing = timings
694                    .input_resolution
695                    .content_hashing
696                    .saturating_add(elapsed);
697                timings.input_resolution.total =
698                    timings.input_resolution.total.saturating_add(elapsed);
699            }
700            WasmBuildFailurePhase::SharedTargetCoordination => {
701                timings.shared_target_coordination = Some(
702                    timings
703                        .shared_target_coordination
704                        .unwrap_or_default()
705                        .saturating_add(elapsed),
706                );
707            }
708            WasmBuildFailurePhase::SharedTargetMaintenance => {
709                timings.shared_target_maintenance = Some(
710                    timings
711                        .shared_target_maintenance
712                        .unwrap_or_default()
713                        .saturating_add(elapsed),
714                );
715            }
716            WasmBuildFailurePhase::CargoBuild => {
717                timings.cargo_build = Some(
718                    timings
719                        .cargo_build
720                        .unwrap_or_default()
721                        .saturating_add(elapsed),
722                );
723            }
724            WasmBuildFailurePhase::ArtifactPublication => {
725                timings.artifact_publication = Some(
726                    timings
727                        .artifact_publication
728                        .unwrap_or_default()
729                        .saturating_add(elapsed),
730                );
731            }
732            WasmBuildFailurePhase::ExactCacheMaintenance => {
733                timings.exact_cache_maintenance = Some(
734                    timings
735                        .exact_cache_maintenance
736                        .unwrap_or_default()
737                        .saturating_add(elapsed),
738                );
739            }
740            WasmBuildFailurePhase::Cleanup => {
741                timings.cleanup = Some(timings.cleanup.unwrap_or_default().saturating_add(elapsed));
742            }
743        }
744    }
745
746    fn failure_details(
747        &self,
748        error: &WasmBuildError,
749        total: Duration,
750    ) -> (WasmBuildFailurePhase, WasmBuildFailureTimings) {
751        let phase = self
752            .failure_phase
753            .unwrap_or_else(|| classify_unobserved_failure(error));
754        let mut timings = self.failure_timings;
755        timings.total = total;
756        (phase, timings)
757    }
758}
759
760const fn progress_failure_phase(phase: WasmBuildProgressPhase) -> WasmBuildFailurePhase {
761    match phase {
762        WasmBuildProgressPhase::ExactCacheLock => WasmBuildFailurePhase::ExactCacheCoordination,
763        WasmBuildProgressPhase::CargoIdentity | WasmBuildProgressPhase::RustcIdentity => {
764            WasmBuildFailurePhase::ToolIdentity
765        }
766        WasmBuildProgressPhase::CargoMetadata => WasmBuildFailurePhase::CargoMetadata,
767        WasmBuildProgressPhase::InputDiscovery => WasmBuildFailurePhase::InputDiscovery,
768        WasmBuildProgressPhase::ContentHashing => WasmBuildFailurePhase::ContentHashing,
769        WasmBuildProgressPhase::SharedTargetLock => WasmBuildFailurePhase::SharedTargetCoordination,
770        WasmBuildProgressPhase::SharedTargetMaintenance => {
771            WasmBuildFailurePhase::SharedTargetMaintenance
772        }
773        WasmBuildProgressPhase::CargoBuild => WasmBuildFailurePhase::CargoBuild,
774        WasmBuildProgressPhase::ArtifactPublication => WasmBuildFailurePhase::ArtifactPublication,
775        WasmBuildProgressPhase::ExactCacheMaintenance => {
776            WasmBuildFailurePhase::ExactCacheMaintenance
777        }
778    }
779}
780
781const fn classify_unobserved_failure(error: &WasmBuildError) -> WasmBuildFailurePhase {
782    match error {
783        WasmBuildError::InvalidSpec { .. } => WasmBuildFailurePhase::Specification,
784        WasmBuildError::CommandSpawn { phase, .. }
785        | WasmBuildError::CommandFailed { phase, .. } => match phase {
786            WasmBuildPhase::CargoIdentity | WasmBuildPhase::RustcIdentity => {
787                WasmBuildFailurePhase::ToolIdentity
788            }
789            WasmBuildPhase::CargoMetadata => WasmBuildFailurePhase::CargoMetadata,
790            WasmBuildPhase::CargoBuild => WasmBuildFailurePhase::CargoBuild,
791        },
792        WasmBuildError::InvalidMetadata { .. } => WasmBuildFailurePhase::CargoMetadata,
793        WasmBuildError::InvalidCargoConfiguration { .. } => WasmBuildFailurePhase::InputDiscovery,
794        WasmBuildError::MissingArtifacts { .. } => WasmBuildFailurePhase::ArtifactPublication,
795        WasmBuildError::InputsChangedDuringBuild { .. } => WasmBuildFailurePhase::ContentHashing,
796        WasmBuildError::PreparedInputSnapshotInvalidated => {
797            WasmBuildFailurePhase::ArtifactPublication
798        }
799        WasmBuildError::FailedBuildCleanup { .. } => WasmBuildFailurePhase::Cleanup,
800        WasmBuildError::Io { .. } => WasmBuildFailurePhase::ExactCacheCoordination,
801    }
802}
803
804/// External phase associated with a cacheable Wasm build failure.
805#[non_exhaustive]
806#[derive(Clone, Copy, Debug, Eq, PartialEq)]
807pub enum WasmBuildPhase {
808    /// Resolving Cargo's package graph.
809    CargoMetadata,
810    /// Reading the Cargo executable identity.
811    CargoIdentity,
812    /// Reading the Rust compiler identity.
813    RustcIdentity,
814    /// Compiling the selected Wasm packages.
815    CargoBuild,
816}
817
818/// Structured failure from a cacheable Wasm build.
819#[non_exhaustive]
820#[derive(Debug)]
821pub enum WasmBuildError {
822    /// The caller supplied an incomplete or inconsistent specification.
823    InvalidSpec { message: String },
824    /// A filesystem operation failed.
825    Io {
826        operation: &'static str,
827        path: PathBuf,
828        source: io::Error,
829    },
830    /// An external command could not be launched.
831    CommandSpawn {
832        phase: WasmBuildPhase,
833        program: OsString,
834        source: io::Error,
835    },
836    /// An external command completed unsuccessfully.
837    CommandFailed {
838        phase: WasmBuildPhase,
839        status: ExitStatus,
840        stdout: String,
841        stderr: String,
842    },
843    /// Cargo metadata did not contain the expected package graph.
844    InvalidMetadata { message: String },
845    /// A discovered Cargo configuration could not be interpreted exactly.
846    InvalidCargoConfiguration { path: PathBuf, message: String },
847    /// Cargo succeeded without producing every declared Wasm artifact.
848    MissingArtifacts { paths: Vec<PathBuf> },
849    /// Declared inputs changed while Cargo was building.
850    InputsChangedDuringBuild {
851        before: InputDigest,
852        after: InputDigest,
853    },
854    /// Another concurrent reader invalidated the prepared input snapshot before publication.
855    PreparedInputSnapshotInvalidated,
856    /// A build failed and its incomplete fingerprint directory could not be removed.
857    FailedBuildCleanup {
858        build_error: Box<Self>,
859        path: PathBuf,
860        source: io::Error,
861    },
862}
863
864impl WasmBuildSpec {
865    /// Describe one Cargo build targeting `wasm32-unknown-unknown`.
866    ///
867    /// `profile_target_dir` is Cargo's output subdirectory, such as `debug`,
868    /// `release`, or the name supplied to `--profile`.
869    #[must_use]
870    pub fn new(
871        workspace_root: &Path,
872        target_dir: &Path,
873        packages: &[&str],
874        profile_target_dir: &str,
875    ) -> Self {
876        Self {
877            workspace_root: workspace_root.to_owned(),
878            target_dir: target_dir.to_owned(),
879            packages: packages
880                .iter()
881                .map(|package| (*package).to_owned())
882                .collect(),
883            profile_target_dir: profile_target_dir.to_owned(),
884            cargo_profile_args: Vec::new(),
885            extra_env: BTreeMap::new(),
886            inherited_env: BTreeSet::new(),
887            additional_inputs: Vec::new(),
888            target: DEFAULT_TARGET.to_owned(),
889            cargo_program: std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()),
890            rustc_program: std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()),
891            cache_mode: WasmBuildCacheMode::Isolated,
892            prune_policy: None,
893            prune_interval: None,
894            shared_incremental_maintenance_config: None,
895        }
896    }
897
898    /// Set Cargo profile and feature arguments used for the build and fingerprint.
899    #[must_use]
900    pub fn with_cargo_profile_args<I, S>(mut self, arguments: I) -> Self
901    where
902        I: IntoIterator<Item = S>,
903        S: AsRef<OsStr>,
904    {
905        self.cargo_profile_args = arguments
906            .into_iter()
907            .map(|argument| argument.as_ref().to_owned())
908            .collect();
909        self
910    }
911
912    /// Set deterministic OS-native child-process environment overrides.
913    #[must_use]
914    pub fn with_extra_env<I, K, V>(mut self, environment: I) -> Self
915    where
916        I: IntoIterator<Item = (K, V)>,
917        K: Into<OsString>,
918        V: Into<OsString>,
919    {
920        self.extra_env = environment
921            .into_iter()
922            .map(|(key, value)| (key.into(), value.into()))
923            .collect();
924        self
925    }
926
927    /// Add ambient environment names whose current values affect the build.
928    ///
929    /// Common Rust and Cargo toolchain variables are included automatically.
930    /// Callers must declare application-specific variables read by build scripts.
931    #[must_use]
932    pub fn with_inherited_env<I, S>(mut self, names: I) -> Self
933    where
934        I: IntoIterator<Item = S>,
935        S: Into<OsString>,
936    {
937        self.inherited_env.extend(names.into_iter().map(Into::into));
938        self
939    }
940
941    /// Add files or directories not discoverable through Cargo's local dependency graph.
942    ///
943    /// Relative paths are resolved from the workspace root. Use this for build
944    /// script configuration, generated schemas, or other externally read inputs.
945    #[must_use]
946    pub fn with_additional_inputs<I, P>(mut self, paths: I) -> Self
947    where
948        I: IntoIterator<Item = P>,
949        P: Into<PathBuf>,
950    {
951        self.additional_inputs
952            .extend(paths.into_iter().map(Into::into));
953        self
954    }
955
956    /// Override the Cargo compilation target.
957    #[must_use]
958    pub fn with_target(mut self, target: &str) -> Self {
959        target.clone_into(&mut self.target);
960        self
961    }
962
963    /// Override the Cargo executable used by metadata, identity, and build commands.
964    #[must_use]
965    pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
966        self.cargo_program = program.into();
967        self
968    }
969
970    /// Override the Rust compiler executable used to fingerprint the toolchain.
971    #[must_use]
972    pub fn with_rustc_program(mut self, program: impl Into<OsString>) -> Self {
973        self.rustc_program = program.into();
974        self
975    }
976
977    /// Build cache misses in one caller-owned shared Cargo incremental target.
978    ///
979    /// Exact final Wasm artifacts still live in the content-addressed cache.
980    /// The shared target is coordinated across processes but is never pruned
981    /// or removed by `ic-testkit` after a failed build.
982    #[must_use]
983    pub fn with_shared_incremental_target(mut self, target_dir: impl Into<PathBuf>) -> Self {
984        self.cache_mode = WasmBuildCacheMode::SharedIncremental {
985            target_dir: target_dir.into(),
986        };
987        self
988    }
989
990    /// Schedule caller-owned shared-target retention as part of acquisition.
991    ///
992    /// This option requires [`Self::with_shared_incremental_target`]. Every
993    /// acquisition coordinates through that target, including an exact hit,
994    /// so a missing target can be created and receive its first schedule
995    /// marker immediately. Matching recent passes only check the marker; due
996    /// passes reuse the acquisition's exact Cargo input resolution before
997    /// evaluating retention. The structured result is attached to the build
998    /// record and emitted through observed progress. Maintenance failures fail
999    /// the acquisition and do not record a successful schedule marker.
1000    #[must_use]
1001    pub const fn with_shared_incremental_target_maintenance_at_most_every(
1002        mut self,
1003        policy: SharedIncrementalTargetPrunePolicy,
1004        minimum_interval: Duration,
1005    ) -> Self {
1006        self.shared_incremental_maintenance_config = Some(
1007            SharedIncrementalTargetMaintenanceConfig::new(policy, minimum_interval),
1008        );
1009        self
1010    }
1011
1012    /// Attach an explicit shared-target maintenance configuration.
1013    ///
1014    /// This is the configurable counterpart to
1015    /// [`Self::with_shared_incremental_target_maintenance_at_most_every`] and
1016    /// supports strict or best-effort failure handling.
1017    #[must_use]
1018    pub const fn with_shared_incremental_target_maintenance(
1019        mut self,
1020        config: SharedIncrementalTargetMaintenanceConfig,
1021    ) -> Self {
1022        self.shared_incremental_maintenance_config = Some(config);
1023        self
1024    }
1025
1026    /// Apply cache retention under the build operation's existing process lock.
1027    ///
1028    /// Maintenance is best-effort: its structured result is attached to the
1029    /// successful build record and cannot turn ready artifacts into a build
1030    /// failure. The active fingerprint is protected from this pruning pass.
1031    #[must_use]
1032    pub const fn with_prune_policy(mut self, policy: ArtifactCachePrunePolicy) -> Self {
1033        self.prune_policy = Some(policy);
1034        self.prune_interval = None;
1035        self
1036    }
1037
1038    /// Apply exact-entry retention at most once per `minimum_interval`.
1039    ///
1040    /// The active fingerprint remains protected. A zero interval is equivalent
1041    /// to [`Self::with_prune_policy`]. The interval covers attempted
1042    /// maintenance, including a nonfatal failed attempt. This schedule never
1043    /// owns or scans a caller-owned shared incremental Cargo target.
1044    #[must_use]
1045    pub const fn with_prune_policy_at_most_every(
1046        mut self,
1047        policy: ArtifactCachePrunePolicy,
1048        minimum_interval: Duration,
1049    ) -> Self {
1050        self.prune_policy = Some(policy);
1051        self.prune_interval = Some(minimum_interval);
1052        self
1053    }
1054
1055    /// Workspace containing the selected Cargo packages.
1056    #[must_use]
1057    pub fn workspace_root(&self) -> &Path {
1058        &self.workspace_root
1059    }
1060
1061    /// Cargo target directory containing artifacts, lock, and stamps.
1062    #[must_use]
1063    pub fn target_dir(&self) -> &Path {
1064        &self.target_dir
1065    }
1066
1067    /// Selected Cargo package names.
1068    #[must_use]
1069    pub fn packages(&self) -> &[String] {
1070        &self.packages
1071    }
1072
1073    /// Cargo-target ownership mode used for cache misses.
1074    #[must_use]
1075    pub const fn cache_mode(&self) -> &WasmBuildCacheMode {
1076        &self.cache_mode
1077    }
1078
1079    /// Exact-entry retention policy attached to this specification, when configured.
1080    #[must_use]
1081    pub const fn prune_policy(&self) -> Option<ArtifactCachePrunePolicy> {
1082        self.prune_policy
1083    }
1084
1085    /// Minimum interval between exact-entry retention attempts, when scheduled.
1086    #[must_use]
1087    pub const fn prune_interval(&self) -> Option<Duration> {
1088        self.prune_interval
1089    }
1090
1091    /// Shared incremental-target maintenance attached to this specification.
1092    #[must_use]
1093    pub const fn shared_incremental_target_maintenance(
1094        &self,
1095    ) -> Option<SharedIncrementalTargetMaintenanceConfig> {
1096        self.shared_incremental_maintenance_config
1097    }
1098}
1099
1100impl WasmBuildOutcome {
1101    /// Read the common build record.
1102    #[must_use]
1103    pub const fn record(&self) -> &WasmBuildRecord {
1104        match self {
1105            Self::Built(record) | Self::Reused(record) => record,
1106        }
1107    }
1108
1109    /// Report whether exact matching artifacts were reused.
1110    #[must_use]
1111    pub const fn is_reused(&self) -> bool {
1112        matches!(self, Self::Reused(_))
1113    }
1114}
1115
1116impl WasmBuildRecord {
1117    /// Exact build fingerprint used by the atomic cache stamp.
1118    #[must_use]
1119    pub const fn fingerprint(&self) -> InputDigest {
1120        self.fingerprint
1121    }
1122
1123    /// Semantic digest of selected package sources and configuration inputs.
1124    #[must_use]
1125    pub const fn input_digest(&self) -> InputDigest {
1126        self.input_digest
1127    }
1128
1129    /// Immutable content-addressed cache directory for this exact build.
1130    ///
1131    /// The directory is selected by the build fingerprint and contains the
1132    /// cached Wasm artifacts and their stamps. The record and its clones retain
1133    /// this entry against pruning until their last drop. A copied path alone
1134    /// does not retain ownership. Treat the contents as read-only.
1135    #[must_use]
1136    pub fn exact_cache_path(&self) -> &Path {
1137        &self.exact_cache_path
1138    }
1139
1140    /// Exact, read-only Wasm paths retained until this record and its clones drop.
1141    ///
1142    /// Keep a record alive throughout post-link processing and reading. Configured
1143    /// materialization destinations remain mutable and are not retained.
1144    #[must_use]
1145    pub fn artifacts(&self) -> &[PathBuf] {
1146        &self.artifacts
1147    }
1148
1149    /// Phase timings captured by the cacheable build operation.
1150    #[must_use]
1151    pub const fn timings(&self) -> WasmBuildTimings {
1152        self.timings
1153    }
1154
1155    /// Cache maintenance attempted under the build lock, when configured.
1156    #[must_use]
1157    pub const fn maintenance(&self) -> Option<&ArtifactCacheMaintenance> {
1158        self.maintenance.as_ref()
1159    }
1160
1161    /// Scheduled caller-owned shared-target maintenance, when configured.
1162    #[must_use]
1163    pub const fn shared_incremental_maintenance(
1164        &self,
1165    ) -> Option<&SharedIncrementalTargetMaintenanceOutcome> {
1166        self.shared_incremental_maintenance.as_ref()
1167    }
1168}
1169
1170impl WasmBuildTimings {
1171    /// Time spent waiting for the output-directory process lock.
1172    #[must_use]
1173    pub const fn lock_wait(self) -> Duration {
1174        self.lock_wait
1175    }
1176
1177    /// Time spent waiting for a shared incremental-target lock, when configured.
1178    #[must_use]
1179    pub const fn shared_incremental_lock_wait(self) -> Option<Duration> {
1180        self.shared_incremental_lock_wait
1181    }
1182
1183    /// Detailed tool, metadata, discovery, and hashing timings.
1184    #[must_use]
1185    pub const fn input_resolution(self) -> WasmInputResolutionTimings {
1186        self.input_resolution
1187    }
1188
1189    /// Time spent in `cargo build`, or `None` for a cache hit.
1190    #[must_use]
1191    pub const fn cargo_build(self) -> Option<Duration> {
1192        self.cargo_build
1193    }
1194
1195    /// Time spent on configured best-effort cache maintenance.
1196    #[must_use]
1197    pub const fn cache_maintenance(self) -> Option<Duration> {
1198        self.cache_maintenance
1199    }
1200
1201    /// Total operation duration, including lock coordination.
1202    #[must_use]
1203    pub const fn total(self) -> Duration {
1204        self.total
1205    }
1206
1207    pub(super) const fn saturating_add(self, other: Self) -> Self {
1208        let mut input_resolution = self.input_resolution;
1209        input_resolution.include(other.input_resolution);
1210        Self {
1211            lock_wait: self.lock_wait.saturating_add(other.lock_wait),
1212            shared_incremental_lock_wait: saturating_add_optional_duration(
1213                self.shared_incremental_lock_wait,
1214                other.shared_incremental_lock_wait,
1215            ),
1216            input_resolution,
1217            cargo_build: saturating_add_optional_duration(self.cargo_build, other.cargo_build),
1218            cache_maintenance: saturating_add_optional_duration(
1219                self.cache_maintenance,
1220                other.cache_maintenance,
1221            ),
1222            total: self.total.saturating_add(other.total),
1223        }
1224    }
1225}
1226
1227impl WasmInputResolutionTimings {
1228    /// Time spent reading Cargo and rustc identities.
1229    #[must_use]
1230    pub const fn tool_identity(self) -> Duration {
1231        self.tool_identity
1232    }
1233
1234    /// Time spent running and decoding `cargo metadata`.
1235    #[must_use]
1236    pub const fn cargo_metadata(self) -> Duration {
1237        self.cargo_metadata
1238    }
1239
1240    /// Time spent resolving packages, configuration, and watched paths.
1241    #[must_use]
1242    pub const fn input_discovery(self) -> Duration {
1243        self.input_discovery
1244    }
1245
1246    /// Time spent reading and hashing exact input contents.
1247    #[must_use]
1248    pub const fn content_hashing(self) -> Duration {
1249        self.content_hashing
1250    }
1251
1252    /// Complete input-resolution duration.
1253    #[must_use]
1254    pub const fn total(self) -> Duration {
1255        self.total
1256    }
1257
1258    const fn include(&mut self, other: Self) {
1259        self.tool_identity = self.tool_identity.saturating_add(other.tool_identity);
1260        self.cargo_metadata = self.cargo_metadata.saturating_add(other.cargo_metadata);
1261        self.input_discovery = self.input_discovery.saturating_add(other.input_discovery);
1262        self.content_hashing = self.content_hashing.saturating_add(other.content_hashing);
1263        self.total = self.total.saturating_add(other.total);
1264    }
1265}
1266
1267impl WasmBuildFailureTimings {
1268    /// Time spent coordinating the exact artifact cache before failure.
1269    #[must_use]
1270    pub const fn exact_cache_coordination(self) -> Duration {
1271        self.exact_cache_coordination
1272    }
1273
1274    /// Time spent coordinating a shared incremental target, when reached.
1275    #[must_use]
1276    pub const fn shared_target_coordination(self) -> Option<Duration> {
1277        self.shared_target_coordination
1278    }
1279
1280    /// Partial Cargo/rustc identity, metadata, discovery, and hashing timings.
1281    #[must_use]
1282    pub const fn input_resolution(self) -> WasmInputResolutionTimings {
1283        self.input_resolution
1284    }
1285
1286    /// Time spent on shared-target maintenance, when reached.
1287    #[must_use]
1288    pub const fn shared_target_maintenance(self) -> Option<Duration> {
1289        self.shared_target_maintenance
1290    }
1291
1292    /// Time spent executing Cargo, including an unsuccessful execution.
1293    #[must_use]
1294    pub const fn cargo_build(self) -> Option<Duration> {
1295        self.cargo_build
1296    }
1297
1298    /// Time spent validating or publishing artifacts, when reached.
1299    #[must_use]
1300    pub const fn artifact_publication(self) -> Option<Duration> {
1301        self.artifact_publication
1302    }
1303
1304    /// Time spent on exact-cache maintenance, when reached.
1305    #[must_use]
1306    pub const fn exact_cache_maintenance(self) -> Option<Duration> {
1307        self.exact_cache_maintenance
1308    }
1309
1310    /// Explicit incomplete-entry cleanup time, when failure required it.
1311    #[must_use]
1312    pub const fn cleanup(self) -> Option<Duration> {
1313        self.cleanup
1314    }
1315
1316    /// Complete failed acquisition wall time.
1317    #[must_use]
1318    pub const fn total(self) -> Duration {
1319        self.total
1320    }
1321}
1322
1323impl CargoBuildInput {
1324    /// Stable checkout-independent label used while hashing this input.
1325    #[must_use]
1326    pub fn label(&self) -> &Path {
1327        &self.label
1328    }
1329
1330    /// Resolved file or directory read by the Cargo build.
1331    #[must_use]
1332    pub fn path(&self) -> &Path {
1333        &self.path
1334    }
1335}
1336
1337impl ResolvedCargoBuildInputs {
1338    /// Exact build fingerprint including Cargo inputs, tools, arguments, and environment.
1339    #[must_use]
1340    pub const fn fingerprint(&self) -> InputDigest {
1341        self.fingerprint
1342    }
1343
1344    /// Semantic digest of selected Cargo sources and workspace configuration.
1345    ///
1346    /// Unlike [`Self::validation_digest`], this may remain unchanged after an
1347    /// unrelated host-only workspace manifest or lockfile update.
1348    #[must_use]
1349    pub const fn input_digest(&self) -> InputDigest {
1350        self.input_digest
1351    }
1352
1353    /// Conservative digest of every raw source and configuration input.
1354    ///
1355    /// This digest is used for mutation guards. It may change while
1356    /// [`Self::input_digest`] and [`Self::fingerprint`] remain unchanged.
1357    #[must_use]
1358    pub const fn validation_digest(&self) -> InputDigest {
1359        self.validation_digest
1360    }
1361
1362    /// Stable logical labels and conservative resolved validation paths.
1363    #[must_use]
1364    pub fn inputs(&self) -> &[CargoBuildInput] {
1365        &self.inputs
1366    }
1367
1368    /// Generated-state roots excluded while recursively hashing local inputs.
1369    ///
1370    /// These exclusions are derived by `ic-testkit`; callers cannot add
1371    /// arbitrary exclusions through this snapshot.
1372    #[must_use]
1373    pub fn exclusions(&self) -> &[PathBuf] {
1374        &self.exclusions
1375    }
1376
1377    /// Timings for tool identity, metadata, discovery, and content hashing.
1378    #[must_use]
1379    pub const fn timings(&self) -> WasmInputResolutionTimings {
1380        self.timings
1381    }
1382
1383    /// Resolve `spec` again and report whether its exact identity is unchanged.
1384    pub fn is_current(&self, spec: &WasmBuildSpec) -> Result<bool, WasmBuildError> {
1385        resolve_cargo_build_inputs(spec).map(|current| current.fingerprint == self.fingerprint)
1386    }
1387
1388    /// Rehash the already discovered Cargo source/configuration set.
1389    ///
1390    /// This is cheaper than rerunning Cargo metadata and is intended for
1391    /// before/after guards around external artifact transformations. Resolve a
1392    /// new snapshot to observe tool, argument, environment, or dependency-graph
1393    /// identity changes between separate acquisitions.
1394    pub fn is_content_current(&self) -> Result<bool, WasmBuildError> {
1395        self.current_validation_digest()
1396            .map(|current| current == self.validation_digest)
1397    }
1398
1399    pub(super) fn current_validation_digest(&self) -> Result<InputDigest, WasmBuildError> {
1400        let inputs = self
1401            .inputs
1402            .iter()
1403            .map(|input| (input.label.clone(), input.path.clone()))
1404            .collect::<Vec<_>>();
1405        digest_labeled_paths_composable(
1406            "wasm-source-inputs-v1",
1407            &inputs,
1408            &self.exclusions,
1409            &mut LabeledPathDigestCache::default(),
1410        )
1411        .map_err(|source| WasmBuildError::Io {
1412            operation: "rehash resolved Cargo build inputs",
1413            path: self
1414                .inputs
1415                .first()
1416                .map_or_else(PathBuf::new, |input| input.path.clone()),
1417            source,
1418        })
1419    }
1420}
1421
1422impl WasmBuildSessionState {
1423    pub(super) fn new() -> Self {
1424        Self {
1425            snapshots: Vec::new(),
1426            digest_cache: LabeledPathDigestCache::default(),
1427            snapshot_reuses: 0,
1428            invalidated: false,
1429        }
1430    }
1431
1432    pub(super) const fn snapshot_count(&self) -> usize {
1433        self.snapshots.len()
1434    }
1435
1436    pub(super) const fn snapshot_reuses(&self) -> usize {
1437        self.snapshot_reuses
1438    }
1439
1440    pub(super) const fn is_invalidated(&self) -> bool {
1441        self.invalidated
1442    }
1443
1444    fn reuse(&mut self, spec: &WasmBuildSpec) -> Option<ResolvedCargoBuildInputs> {
1445        let (_, snapshot) = self
1446            .snapshots
1447            .iter()
1448            .find(|(candidate, _)| candidate == spec)?;
1449        self.snapshot_reuses = self.snapshot_reuses.saturating_add(1);
1450        let mut snapshot = snapshot.clone();
1451        snapshot.timings = WasmInputResolutionTimings::default();
1452        Some(snapshot)
1453    }
1454
1455    fn remember(&mut self, spec: &WasmBuildSpec, resolved: &ResolvedCargoBuildInputs) {
1456        if self
1457            .snapshots
1458            .iter()
1459            .any(|(candidate, _)| candidate == spec)
1460        {
1461            return;
1462        }
1463        let mut snapshot = resolved.clone();
1464        snapshot.timings = WasmInputResolutionTimings::default();
1465        self.snapshots.push((spec.clone(), snapshot));
1466    }
1467
1468    fn invalidate(&mut self) {
1469        self.snapshots.clear();
1470        self.digest_cache = LabeledPathDigestCache::default();
1471        self.invalidated = true;
1472    }
1473}
1474
1475impl WasmBuildInputSnapshotState {
1476    pub(super) fn prepare(specs: &[WasmBuildSpec]) -> Result<Self, WasmBuildError> {
1477        for spec in specs {
1478            validate_spec(spec)?;
1479        }
1480        let mut resolver = WasmBuildBatchInputResolver::new(specs);
1481        let mut snapshots = Vec::with_capacity(specs.len());
1482        let mut preparation_timings = WasmInputResolutionTimings::default();
1483        let mut progress = ProgressReporter::silent();
1484        for (index, spec) in specs.iter().enumerate() {
1485            let mut prepared_input = resolver.resolve(index, &mut progress)?;
1486            preparation_timings.include(prepared_input.timings);
1487            prepared_input.timings = WasmInputResolutionTimings::default();
1488            snapshots.push((spec.clone(), prepared_input));
1489        }
1490        Ok(Self {
1491            snapshots,
1492            preparation_metrics: resolver.metrics(),
1493            preparation_timings,
1494            reader_reuses: AtomicUsize::new(0),
1495            invalidation: Arc::new(RwLock::new(false)),
1496        })
1497    }
1498
1499    pub(super) fn contains(&self, spec: &WasmBuildSpec) -> bool {
1500        self.snapshots
1501            .iter()
1502            .any(|(candidate, _)| candidate == spec)
1503    }
1504
1505    fn reuse(&self, spec: &WasmBuildSpec) -> Option<ResolvedCargoBuildInputs> {
1506        let (_, snapshot) = self
1507            .snapshots
1508            .iter()
1509            .find(|(candidate, _)| candidate == spec)?;
1510        let _ = self
1511            .reader_reuses
1512            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
1513                Some(current.saturating_add(1))
1514            });
1515        Some(snapshot.clone())
1516    }
1517
1518    pub(super) const fn specification_count(&self) -> usize {
1519        self.snapshots.len()
1520    }
1521
1522    pub(super) const fn preparation_metrics(&self) -> WasmBuildBatchInputMetrics {
1523        self.preparation_metrics
1524    }
1525
1526    pub(super) const fn preparation_timings(&self) -> WasmInputResolutionTimings {
1527        self.preparation_timings
1528    }
1529
1530    pub(super) fn reader_reuses(&self) -> usize {
1531        self.reader_reuses.load(Ordering::Relaxed)
1532    }
1533
1534    pub(super) fn is_invalidated(&self) -> bool {
1535        *self
1536            .invalidation
1537            .read()
1538            .unwrap_or_else(std::sync::PoisonError::into_inner)
1539    }
1540
1541    fn invalidate(&self) {
1542        *self
1543            .invalidation
1544            .write()
1545            .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
1546    }
1547
1548    fn invalidation(&self) -> Arc<RwLock<bool>> {
1549        Arc::clone(&self.invalidation)
1550    }
1551}
1552
1553impl<'a, 'session> WasmBuildBatchInputResolver<'a, 'session> {
1554    pub(super) fn new(specs: &'a [WasmBuildSpec]) -> Self {
1555        Self::create(specs, None, None)
1556    }
1557
1558    pub(super) fn with_session(
1559        specs: &'a [WasmBuildSpec],
1560        session: &'session mut WasmBuildSessionState,
1561    ) -> Self {
1562        Self::create(specs, Some(session), None)
1563    }
1564
1565    pub(super) fn with_snapshot(
1566        specs: &'a [WasmBuildSpec],
1567        snapshot: &'session WasmBuildInputSnapshotState,
1568    ) -> Self {
1569        Self::create(specs, None, Some(snapshot))
1570    }
1571
1572    fn create(
1573        specs: &'a [WasmBuildSpec],
1574        mut session: Option<&'session mut WasmBuildSessionState>,
1575        snapshot: Option<&'session WasmBuildInputSnapshotState>,
1576    ) -> Self {
1577        let mut keys = Vec::<BatchResolutionKey>::new();
1578        let mut groups = Vec::<BatchResolutionGroup>::new();
1579        let mut group_by_index = Vec::with_capacity(specs.len());
1580        for (index, spec) in specs.iter().enumerate() {
1581            let key = BatchResolutionKey::for_spec(spec);
1582            let group = keys
1583                .iter()
1584                .position(|candidate| *candidate == key)
1585                .unwrap_or_else(|| {
1586                    keys.push(key);
1587                    groups.push(BatchResolutionGroup {
1588                        indexes: Vec::new(),
1589                    });
1590                    groups.len() - 1
1591                });
1592            groups[group].indexes.push(index);
1593            group_by_index.push(group);
1594        }
1595        let mut metrics = WasmBuildBatchInputMetrics::default();
1596        let resolved = specs
1597            .iter()
1598            .map(|spec| {
1599                let session_reused = session
1600                    .as_deref_mut()
1601                    .and_then(|session| session.reuse(spec));
1602                if session_reused.is_some() {
1603                    metrics.session_reuses = metrics.session_reuses.saturating_add(1);
1604                    return session_reused.map(Ok);
1605                }
1606                if let Some(snapshot) = snapshot {
1607                    let reused = snapshot
1608                        .reuse(spec)
1609                        .expect("prepared input snapshot must contain every reader specification");
1610                    metrics.prepared_reuses = metrics.prepared_reuses.saturating_add(1);
1611                    return Some(Ok(reused));
1612                }
1613                None
1614            })
1615            .collect();
1616        Self {
1617            specs,
1618            groups,
1619            group_by_index,
1620            resolved,
1621            session,
1622            snapshot,
1623            metrics,
1624        }
1625    }
1626
1627    pub(super) const fn metrics(&self) -> WasmBuildBatchInputMetrics {
1628        self.metrics
1629    }
1630
1631    pub(super) fn invalidate_source_lease(&mut self) {
1632        if let Some(session) = self.session.as_deref_mut() {
1633            session.invalidate();
1634            // Every unresolved entry was captured before the detected source race,
1635            // including entries resolved only for this batch. Force later entries
1636            // through fresh discovery instead of consuming a now-stale snapshot.
1637            for resolved in &mut self.resolved {
1638                *resolved = None;
1639            }
1640            self.session = None;
1641        }
1642        if let Some(snapshot) = self.snapshot {
1643            snapshot.invalidate();
1644        }
1645    }
1646
1647    pub(super) const fn assumes_sources_immutable(&self) -> bool {
1648        self.session.is_some() || self.snapshot.is_some()
1649    }
1650
1651    pub(super) fn prepared_invalidation(&self) -> Option<Arc<RwLock<bool>>> {
1652        self.snapshot.map(WasmBuildInputSnapshotState::invalidation)
1653    }
1654
1655    fn resolve(
1656        &mut self,
1657        index: usize,
1658        progress: &mut ProgressReporter<'_>,
1659    ) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1660        if self.resolved[index].is_none() {
1661            self.resolve_group(index, progress)?;
1662        }
1663        self.resolved[index]
1664            .take()
1665            .expect("resolved batch input must be populated")
1666    }
1667
1668    fn resolve_group(
1669        &mut self,
1670        active_index: usize,
1671        progress: &mut ProgressReporter<'_>,
1672    ) -> Result<(), WasmBuildError> {
1673        let total_started = Instant::now();
1674        let indexes = self.groups[self.group_by_index[active_index]]
1675            .indexes
1676            .clone();
1677        let active = &self.specs[active_index];
1678
1679        let (cargo_identity, rustc_identity, tool_identity) =
1680            resolve_batch_tool_identity(active, progress)?;
1681
1682        let metadata_started = Instant::now();
1683        let metadata = progress.run_phase(WasmBuildProgressPhase::CargoMetadata, || {
1684            cargo_metadata(active)
1685        })?;
1686        let cargo_metadata = metadata_started.elapsed();
1687
1688        let (discovered, input_discovery) =
1689            self.discover_group_inputs(indexes, &metadata, progress);
1690
1691        let hashing_started = Instant::now();
1692        let mut batch_digest_cache = LabeledPathDigestCache::default();
1693        let digest_cache = self
1694            .session
1695            .as_deref_mut()
1696            .map_or(&mut batch_digest_cache, |session| &mut session.digest_cache);
1697        let workspace_root = active.workspace_root.clone();
1698        let resolved_inputs = progress.run_phase(WasmBuildProgressPhase::ContentHashing, || {
1699            discovered
1700                .into_iter()
1701                .map(|(index, inputs, exclusions)| {
1702                    let (input_digest, validation_digest) = digest_resolved_local_inputs(
1703                        &inputs,
1704                        &exclusions,
1705                        digest_cache,
1706                        &workspace_root,
1707                        "hash batched Wasm build inputs",
1708                        "hash batched semantic Wasm build inputs",
1709                    )?;
1710                    Ok::<_, WasmBuildError>((
1711                        index,
1712                        inputs.validation_inputs,
1713                        exclusions,
1714                        input_digest,
1715                        validation_digest,
1716                    ))
1717                })
1718                .collect::<Result<Vec<_>, _>>()
1719        })?;
1720        let content_hashing = hashing_started.elapsed();
1721        let timings = WasmInputResolutionTimings {
1722            tool_identity,
1723            cargo_metadata,
1724            input_discovery,
1725            content_hashing,
1726            total: total_started.elapsed(),
1727        };
1728        let resolved_count = resolved_inputs.len();
1729        if resolved_count > 0 {
1730            self.metrics.runs += 1;
1731            self.metrics.reuses += resolved_count.saturating_sub(1);
1732        }
1733        let timing_index = resolved_inputs
1734            .iter()
1735            .any(|(index, ..)| *index == active_index)
1736            .then_some(active_index)
1737            .or_else(|| resolved_inputs.first().map(|(index, ..)| *index));
1738        for (index, inputs, exclusions, input_digest, validation_digest) in resolved_inputs {
1739            let spec = &self.specs[index];
1740            let resolved = ResolvedCargoBuildInputs {
1741                fingerprint: finish_build_fingerprint(
1742                    spec,
1743                    &cargo_identity,
1744                    &rustc_identity,
1745                    input_digest,
1746                ),
1747                input_digest,
1748                validation_digest,
1749                inputs: inputs
1750                    .into_iter()
1751                    .map(|(label, path)| CargoBuildInput { label, path })
1752                    .collect(),
1753                exclusions,
1754                timings: if Some(index) == timing_index {
1755                    timings
1756                } else {
1757                    WasmInputResolutionTimings::default()
1758                },
1759            };
1760            if let Some(session) = self.session.as_deref_mut() {
1761                session.remember(spec, &resolved);
1762            }
1763            self.resolved[index] = Some(Ok(resolved));
1764        }
1765        Ok(())
1766    }
1767
1768    fn discover_group_inputs(
1769        &mut self,
1770        indexes: Vec<usize>,
1771        metadata: &Value,
1772        progress: &mut ProgressReporter<'_>,
1773    ) -> (Vec<(usize, ResolvedLocalInputs, Vec<PathBuf>)>, Duration) {
1774        let started = Instant::now();
1775        let pending = indexes
1776            .into_iter()
1777            .filter(|index| {
1778                self.resolved[*index].is_none() && validate_spec(&self.specs[*index]).is_ok()
1779            })
1780            .collect::<Vec<_>>();
1781        let results = progress.run_phase(WasmBuildProgressPhase::InputDiscovery, || {
1782            pending
1783                .into_iter()
1784                .map(|index| {
1785                    let spec = &self.specs[index];
1786                    let result = (|| {
1787                        let inputs = resolve_local_inputs(spec, metadata)?;
1788                        validate_shared_incremental_target_boundary(
1789                            spec,
1790                            &inputs.validation_inputs,
1791                        )?;
1792                        let exclusions = source_exclusions(spec, &inputs.validation_inputs);
1793                        Ok::<_, WasmBuildError>((inputs, exclusions))
1794                    })();
1795                    (index, result)
1796                })
1797                .collect::<Vec<_>>()
1798        });
1799        let mut discovered = Vec::new();
1800        for (index, result) in results {
1801            match result {
1802                Ok((inputs, exclusions)) => discovered.push((index, inputs, exclusions)),
1803                Err(error) => self.resolved[index] = Some(Err(error)),
1804            }
1805        }
1806        (discovered, started.elapsed())
1807    }
1808}
1809
1810fn resolve_batch_tool_identity(
1811    spec: &WasmBuildSpec,
1812    progress: &mut ProgressReporter<'_>,
1813) -> Result<(Vec<u8>, Vec<u8>, Duration), WasmBuildError> {
1814    let started = Instant::now();
1815    let cargo_identity = progress.run_phase(WasmBuildProgressPhase::CargoIdentity, || {
1816        command_identity(
1817            spec,
1818            WasmBuildPhase::CargoIdentity,
1819            &spec.cargo_program,
1820            &["--version", "--verbose"],
1821        )
1822    })?;
1823    let rustc_program = spec
1824        .extra_env
1825        .get(OsStr::new("RUSTC"))
1826        .unwrap_or(&spec.rustc_program);
1827    let rustc_identity = progress.run_phase(WasmBuildProgressPhase::RustcIdentity, || {
1828        command_identity(spec, WasmBuildPhase::RustcIdentity, rustc_program, &["-vV"])
1829    })?;
1830    Ok((cargo_identity, rustc_identity, started.elapsed()))
1831}
1832
1833impl BatchResolutionKey {
1834    fn for_spec(spec: &WasmBuildSpec) -> Self {
1835        Self {
1836            workspace_root: spec.workspace_root.clone(),
1837            cargo_program: spec.cargo_program.clone(),
1838            rustc_program: spec
1839                .extra_env
1840                .get(OsStr::new("RUSTC"))
1841                .unwrap_or(&spec.rustc_program)
1842                .clone(),
1843            metadata_arguments: metadata_arguments(&spec.cargo_profile_args),
1844            environment: effective_environment(spec),
1845        }
1846    }
1847}
1848
1849impl SharedIncrementalTargetInspection {
1850    /// Canonical shared Cargo target directory that was inspected.
1851    #[must_use]
1852    pub fn target_dir(&self) -> &Path {
1853        &self.target_dir
1854    }
1855
1856    /// Logical bytes currently occupied by the complete shared target.
1857    #[must_use]
1858    pub const fn logical_size_bytes(&self) -> u64 {
1859        self.logical_size_bytes
1860    }
1861
1862    /// Most recent build use recorded by `ic-testkit`, or the directory mtime for older targets.
1863    #[must_use]
1864    pub const fn last_used(&self) -> SystemTime {
1865        self.last_used
1866    }
1867
1868    /// Time spent waiting for another process using the shared target.
1869    #[must_use]
1870    pub const fn lock_wait(&self) -> Duration {
1871        self.lock_wait
1872    }
1873}
1874
1875impl SharedIncrementalTargetPrunePolicy {
1876    /// Create an explicit policy without a clearing threshold.
1877    #[must_use]
1878    pub const fn new() -> Self {
1879        Self {
1880            max_age: None,
1881            max_size_bytes: None,
1882        }
1883    }
1884
1885    /// Clear shared Cargo state when its recorded use is older than `max_age`.
1886    #[must_use]
1887    pub const fn with_max_age(mut self, max_age: Duration) -> Self {
1888        self.max_age = Some(max_age);
1889        self
1890    }
1891
1892    /// Clear shared Cargo state when its logical size exceeds `bytes`.
1893    #[must_use]
1894    pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
1895        self.max_size_bytes = Some(bytes);
1896        self
1897    }
1898
1899    /// Configured maximum time since recorded build use.
1900    #[must_use]
1901    pub const fn max_age(self) -> Option<Duration> {
1902        self.max_age
1903    }
1904
1905    /// Configured maximum logical target size.
1906    #[must_use]
1907    pub const fn max_size_bytes(self) -> Option<u64> {
1908        self.max_size_bytes
1909    }
1910
1911    fn maintenance_identity(self) -> String {
1912        format!(
1913            "age={:?};size={:?}",
1914            self.max_age.map(|duration| duration.as_nanos()),
1915            self.max_size_bytes
1916        )
1917    }
1918}
1919
1920impl SharedIncrementalTargetMaintenanceConfig {
1921    /// Schedule one strict retention pass at most once per interval.
1922    #[must_use]
1923    pub const fn new(
1924        policy: SharedIncrementalTargetPrunePolicy,
1925        minimum_interval: Duration,
1926    ) -> Self {
1927        Self {
1928            policy,
1929            minimum_interval,
1930            failure_mode: SharedIncrementalTargetMaintenanceFailureMode::Strict,
1931        }
1932    }
1933
1934    /// Select whether an integrated maintenance failure fails the acquisition.
1935    #[must_use]
1936    pub const fn with_failure_mode(
1937        mut self,
1938        failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
1939    ) -> Self {
1940        self.failure_mode = failure_mode;
1941        self
1942    }
1943
1944    /// Configured whole-target retention policy.
1945    #[must_use]
1946    pub const fn policy(self) -> SharedIncrementalTargetPrunePolicy {
1947        self.policy
1948    }
1949
1950    /// Minimum interval between successful matching maintenance passes.
1951    #[must_use]
1952    pub const fn minimum_interval(self) -> Duration {
1953        self.minimum_interval
1954    }
1955
1956    /// Configured maintenance failure handling.
1957    #[must_use]
1958    pub const fn failure_mode(self) -> SharedIncrementalTargetMaintenanceFailureMode {
1959        self.failure_mode
1960    }
1961}
1962
1963impl SharedIncrementalTargetMaintenance {
1964    /// Canonical shared Cargo target directory maintained under lock.
1965    #[must_use]
1966    pub fn target_dir(&self) -> &Path {
1967        &self.target_dir
1968    }
1969
1970    /// Logical bytes observed before applying the policy.
1971    #[must_use]
1972    pub const fn logical_size_bytes_before(&self) -> u64 {
1973        self.logical_size_bytes_before
1974    }
1975
1976    /// Logical bytes retained after applying the policy.
1977    #[must_use]
1978    pub const fn logical_size_bytes_after(&self) -> u64 {
1979        self.logical_size_bytes_after
1980    }
1981
1982    /// Most recent build use observed before applying the policy.
1983    #[must_use]
1984    pub const fn last_used_before(&self) -> SystemTime {
1985        self.last_used_before
1986    }
1987
1988    /// Whether a configured limit caused the mutable target contents to be cleared.
1989    #[must_use]
1990    pub const fn was_cleared(&self) -> bool {
1991        self.cleared
1992    }
1993
1994    /// Time spent waiting for another process using the shared target.
1995    #[must_use]
1996    pub const fn lock_wait(&self) -> Duration {
1997        self.lock_wait
1998    }
1999
2000    /// Time spent measuring and, when required, clearing the target.
2001    #[must_use]
2002    pub const fn maintenance(&self) -> Duration {
2003        self.maintenance
2004    }
2005}
2006
2007impl std::fmt::Display for SharedIncrementalTargetMaintenance {
2008    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2009        write!(
2010            formatter,
2011            "target={} action={} bytes={}=>{} lock={:?} maintenance={:?}",
2012            self.target_dir.display(),
2013            if self.cleared { "cleared" } else { "retained" },
2014            self.logical_size_bytes_before,
2015            self.logical_size_bytes_after,
2016            self.lock_wait,
2017            self.maintenance,
2018        )
2019    }
2020}
2021
2022impl SharedIncrementalTargetMaintenanceOutcome {
2023    /// Configured or canonical target associated with this result.
2024    #[must_use]
2025    pub fn target_dir(&self) -> &Path {
2026        match self {
2027            Self::Missing { target_dir }
2028            | Self::Skipped { target_dir, .. }
2029            | Self::Failed { target_dir, .. } => target_dir,
2030            Self::Performed { maintenance, .. } => maintenance.target_dir(),
2031        }
2032    }
2033
2034    /// Completed maintenance report, when retention was evaluated.
2035    #[must_use]
2036    pub const fn maintenance(&self) -> Option<&SharedIncrementalTargetMaintenance> {
2037        match self {
2038            Self::Performed { maintenance, .. } => Some(maintenance),
2039            Self::Missing { .. } | Self::Skipped { .. } | Self::Failed { .. } => None,
2040        }
2041    }
2042
2043    /// Whether retention was evaluated during this call.
2044    #[must_use]
2045    pub const fn was_performed(&self) -> bool {
2046        matches!(self, Self::Performed { .. })
2047    }
2048
2049    /// Time spent waiting for another process, when the target existed.
2050    #[must_use]
2051    pub const fn lock_wait(&self) -> Option<Duration> {
2052        match self {
2053            Self::Missing { .. } => None,
2054            Self::Skipped { lock_wait, .. } | Self::Failed { lock_wait, .. } => Some(*lock_wait),
2055            Self::Performed { maintenance, .. } => Some(maintenance.lock_wait()),
2056        }
2057    }
2058
2059    /// Time spent checking the schedule marker, when the target existed.
2060    #[must_use]
2061    pub const fn schedule_check(&self) -> Option<Duration> {
2062        match self {
2063            Self::Missing { .. } | Self::Failed { .. } => None,
2064            Self::Skipped { schedule_check, .. } | Self::Performed { schedule_check, .. } => {
2065                Some(*schedule_check)
2066            }
2067        }
2068    }
2069
2070    /// Rendered integrated maintenance failure, when best-effort handling preserved acquisition.
2071    #[must_use]
2072    pub fn failure_message(&self) -> Option<&str> {
2073        match self {
2074            Self::Failed { message, .. } => Some(message),
2075            Self::Missing { .. } | Self::Skipped { .. } | Self::Performed { .. } => None,
2076        }
2077    }
2078}
2079
2080impl std::fmt::Display for SharedIncrementalTargetMaintenanceOutcome {
2081    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2082        match self {
2083            Self::Missing { target_dir } => {
2084                write!(formatter, "target={} action=missing", target_dir.display())
2085            }
2086            Self::Skipped {
2087                target_dir,
2088                lock_wait,
2089                schedule_check,
2090            } => write!(
2091                formatter,
2092                "target={} action=skipped lock={lock_wait:?} schedule={schedule_check:?}",
2093                target_dir.display(),
2094            ),
2095            Self::Performed {
2096                maintenance,
2097                schedule_check,
2098            } => write!(formatter, "{maintenance} schedule={schedule_check:?}"),
2099            Self::Failed {
2100                target_dir,
2101                lock_wait,
2102                message,
2103            } => write!(
2104                formatter,
2105                "target={} action=failed lock={lock_wait:?} error={message}",
2106                target_dir.display(),
2107            ),
2108        }
2109    }
2110}
2111
2112impl std::fmt::Display for WasmBuildTimings {
2113    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2114        write!(
2115            formatter,
2116            "total={:?} lock={:?} shared_lock={:?} inputs={:?} cargo={:?} maintenance={:?}",
2117            self.total,
2118            self.lock_wait,
2119            self.shared_incremental_lock_wait,
2120            self.input_resolution.total,
2121            self.cargo_build,
2122            self.cache_maintenance,
2123        )
2124    }
2125}
2126
2127impl std::fmt::Display for WasmBuildOutcome {
2128    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2129        let state = if self.is_reused() { "reused" } else { "built" };
2130        write!(
2131            formatter,
2132            "{state} fingerprint={} artifacts={} {}",
2133            self.record().fingerprint,
2134            self.record().artifacts.len(),
2135            self.record().timings,
2136        )?;
2137        if let Some(maintenance) = self.record().shared_incremental_maintenance() {
2138            write!(formatter, " shared_maintenance=({maintenance})")?;
2139        }
2140        Ok(())
2141    }
2142}
2143
2144/// Resolve the exact Cargo source, configuration, toolchain, argument, and environment identity.
2145///
2146/// This performs the same resolution used before and after cached Wasm builds
2147/// without running `cargo build`.
2148pub fn resolve_cargo_build_inputs(
2149    spec: &WasmBuildSpec,
2150) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2151    validate_spec(spec)?;
2152    build_fingerprint(spec)
2153}
2154
2155/// Inspect one configured shared Cargo target under its build coordination lock.
2156///
2157/// Returns `None` without creating anything when the caller-owned target does
2158/// not exist. This operation never removes Cargo state.
2159pub fn inspect_shared_incremental_target(
2160    spec: &WasmBuildSpec,
2161) -> Result<Option<SharedIncrementalTargetInspection>, WasmBuildError> {
2162    if !shared_incremental_target_exists(spec, "inspect shared incremental Cargo target")? {
2163        return Ok(None);
2164    }
2165
2166    let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2167    let logical_size_bytes =
2168        directory_logical_size(&canonical).map_err(|source| WasmBuildError::Io {
2169            operation: "measure shared incremental Cargo target",
2170            path: canonical.clone(),
2171            source,
2172        })?;
2173    let last_used = cache_entry_last_used(&canonical).map_err(|source| WasmBuildError::Io {
2174        operation: "read shared incremental Cargo target use time",
2175        path: canonical.clone(),
2176        source,
2177    })?;
2178    Ok(Some(SharedIncrementalTargetInspection {
2179        target_dir: canonical,
2180        logical_size_bytes,
2181        last_used,
2182        lock_wait,
2183    }))
2184}
2185
2186/// Apply explicit whole-target retention to caller-owned shared Cargo state.
2187///
2188/// Returns `None` without creating anything when the target does not exist.
2189/// Policy evaluation and any clearing occur under the same cross-process lock
2190/// used by shared-incremental builds. The target root, `CACHEDIR.TAG`, and
2191/// `.ic-testkit` lock metadata are preserved, so another process cannot enter
2192/// through a replacement lock while maintenance is active.
2193/// Every other target child is removed when a limit is exceeded; unrelated
2194/// data that must survive must not be colocated there. Exact Cargo input
2195/// resolution first rejects targets overlapping source or configuration.
2196///
2197/// This function is never called automatically by exact Wasm acquisitions.
2198/// Consumers retain ownership of when mutable incremental state may be lost.
2199pub fn maintain_shared_incremental_target(
2200    spec: &WasmBuildSpec,
2201    policy: SharedIncrementalTargetPrunePolicy,
2202) -> Result<Option<SharedIncrementalTargetMaintenance>, WasmBuildError> {
2203    if !shared_incremental_target_exists(
2204        spec,
2205        "inspect shared incremental Cargo target before maintenance",
2206    )? {
2207        return Ok(None);
2208    }
2209
2210    // Reuse the exact build resolver so destructive maintenance cannot act on
2211    // a target that overlaps Cargo sources, configuration, or additional
2212    // inputs. The target itself is excluded as generated state during hashing.
2213    let _ = resolve_cargo_build_inputs(spec)?;
2214    let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2215    maintain_shared_incremental_target_locked(&canonical, policy, lock_wait).map(Some)
2216}
2217
2218/// Apply whole-target retention at most once per interval across processes.
2219///
2220/// The schedule marker is checked under the same lock used by shared Cargo
2221/// builds. A matching successful pass inside `minimum_interval` returns
2222/// [`SharedIncrementalTargetMaintenanceOutcome::Skipped`] without resolving
2223/// Cargo inputs or traversing the target. Missing targets are not created.
2224/// Changing the policy makes maintenance immediately due, and a zero interval
2225/// always evaluates retention.
2226///
2227/// Due maintenance performs exact Cargo input resolution before inspecting or
2228/// clearing the target. Failures are returned and are not recorded as a
2229/// successful pass, so an unsafe configuration cannot be hidden by the
2230/// schedule.
2231pub fn maintain_shared_incremental_target_at_most_every(
2232    spec: &WasmBuildSpec,
2233    policy: SharedIncrementalTargetPrunePolicy,
2234    minimum_interval: Duration,
2235) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2236    let target_dir =
2237        shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
2238            message: "shared incremental target is not configured".to_owned(),
2239        })?;
2240    if !shared_incremental_target_exists(
2241        spec,
2242        "inspect shared incremental Cargo target before scheduled maintenance",
2243    )? {
2244        return Ok(SharedIncrementalTargetMaintenanceOutcome::Missing { target_dir });
2245    }
2246
2247    let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2248    let schedule = schedule_shared_incremental_target_maintenance(
2249        &canonical,
2250        policy,
2251        minimum_interval,
2252        lock_wait,
2253    )?;
2254    let schedule = match schedule {
2255        SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => return Ok(outcome),
2256        SharedIncrementalTargetMaintenanceSchedule::Due(due) => due,
2257    };
2258
2259    // Keep the schedule decision and maintenance in one critical section so
2260    // concurrent test binaries cannot all perform the same expensive scan.
2261    let _ = resolve_cargo_build_inputs(spec)?;
2262    perform_due_shared_incremental_target_maintenance(&canonical, policy, lock_wait, schedule)
2263}
2264
2265enum SharedIncrementalTargetMaintenanceSchedule {
2266    Skipped(SharedIncrementalTargetMaintenanceOutcome),
2267    Due(DueSharedIncrementalTargetMaintenance),
2268}
2269
2270struct DueSharedIncrementalTargetMaintenance {
2271    schedule_root: PathBuf,
2272    maintenance_identity: String,
2273    schedule_check: Duration,
2274}
2275
2276fn schedule_shared_incremental_target_maintenance(
2277    canonical: &Path,
2278    policy: SharedIncrementalTargetPrunePolicy,
2279    minimum_interval: Duration,
2280    lock_wait: Duration,
2281) -> Result<SharedIncrementalTargetMaintenanceSchedule, WasmBuildError> {
2282    let schedule_root = canonical.join(".ic-testkit");
2283    let maintenance_identity = policy.maintenance_identity();
2284    let schedule_started = Instant::now();
2285    let due = cache_maintenance_due(
2286        &schedule_root,
2287        Some(minimum_interval),
2288        &maintenance_identity,
2289    )
2290    .map_err(wasm_cache_fs_error)?;
2291    let schedule_check = schedule_started.elapsed();
2292    if !due {
2293        return Ok(SharedIncrementalTargetMaintenanceSchedule::Skipped(
2294            SharedIncrementalTargetMaintenanceOutcome::Skipped {
2295                target_dir: canonical.to_owned(),
2296                lock_wait,
2297                schedule_check,
2298            },
2299        ));
2300    }
2301    Ok(SharedIncrementalTargetMaintenanceSchedule::Due(
2302        DueSharedIncrementalTargetMaintenance {
2303            schedule_root,
2304            maintenance_identity,
2305            schedule_check,
2306        },
2307    ))
2308}
2309
2310fn perform_due_shared_incremental_target_maintenance(
2311    canonical: &Path,
2312    policy: SharedIncrementalTargetPrunePolicy,
2313    lock_wait: Duration,
2314    due: DueSharedIncrementalTargetMaintenance,
2315) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2316    let DueSharedIncrementalTargetMaintenance {
2317        schedule_root,
2318        maintenance_identity,
2319        schedule_check,
2320    } = due;
2321    let maintenance = maintain_shared_incremental_target_locked(canonical, policy, lock_wait)?;
2322    record_cache_maintenance(&schedule_root, &maintenance_identity).map_err(wasm_cache_fs_error)?;
2323    Ok(SharedIncrementalTargetMaintenanceOutcome::Performed {
2324        maintenance,
2325        schedule_check,
2326    })
2327}
2328
2329fn maintain_shared_incremental_target_locked(
2330    canonical: &Path,
2331    policy: SharedIncrementalTargetPrunePolicy,
2332    lock_wait: Duration,
2333) -> Result<SharedIncrementalTargetMaintenance, WasmBuildError> {
2334    let started = Instant::now();
2335    let logical_size_bytes_before =
2336        directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2337            operation: "measure shared incremental Cargo target before maintenance",
2338            path: canonical.to_owned(),
2339            source,
2340        })?;
2341    let last_used_before =
2342        cache_entry_last_used(canonical).map_err(|source| WasmBuildError::Io {
2343            operation: "read shared incremental Cargo target use time before maintenance",
2344            path: canonical.to_owned(),
2345            source,
2346        })?;
2347    let expired = policy.max_age.is_some_and(|max_age| {
2348        SystemTime::now()
2349            .duration_since(last_used_before)
2350            .is_ok_and(|age| age > max_age)
2351    });
2352    let oversized = policy
2353        .max_size_bytes
2354        .is_some_and(|max_size_bytes| logical_size_bytes_before > max_size_bytes);
2355    let cleared = expired || oversized;
2356    if cleared {
2357        clear_shared_incremental_target_contents(canonical)?;
2358        record_cache_entry_use(canonical)?;
2359    }
2360    let logical_size_bytes_after = if cleared {
2361        directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2362            operation: "measure shared incremental Cargo target after maintenance",
2363            path: canonical.to_owned(),
2364            source,
2365        })?
2366    } else {
2367        logical_size_bytes_before
2368    };
2369    Ok(SharedIncrementalTargetMaintenance {
2370        target_dir: canonical.to_owned(),
2371        logical_size_bytes_before,
2372        logical_size_bytes_after,
2373        last_used_before,
2374        cleared,
2375        lock_wait,
2376        maintenance: started.elapsed(),
2377    })
2378}
2379
2380fn clear_shared_incremental_target_contents(target_dir: &Path) -> Result<(), WasmBuildError> {
2381    let entries = fs::read_dir(target_dir).map_err(|source| WasmBuildError::Io {
2382        operation: "read shared incremental Cargo target for maintenance",
2383        path: target_dir.to_owned(),
2384        source,
2385    })?;
2386    for entry in entries {
2387        let path = entry
2388            .map_err(|source| WasmBuildError::Io {
2389                operation: "read shared incremental Cargo target entry for maintenance",
2390                path: target_dir.to_owned(),
2391                source,
2392            })?
2393            .path();
2394        let preserved = path
2395            .file_name()
2396            .is_some_and(|name| name == ".ic-testkit" || name == "CACHEDIR.TAG");
2397        if !preserved {
2398            remove_path_if_present(&path).map_err(|source| WasmBuildError::Io {
2399                operation: "clear shared incremental Cargo target entry",
2400                path,
2401                source,
2402            })?;
2403        }
2404    }
2405    Ok(())
2406}
2407
2408/// Build or reuse one exact set of Cargo Wasm artifacts.
2409///
2410/// The operation takes an exclusive process lock scoped to `target_dir`, then
2411/// fingerprints all declared inputs. A cache hit requires both a matching
2412/// atomic stamp and every expected nonempty Wasm output. Failed or interrupted
2413/// builds never publish a successful stamp.
2414pub fn build_wasm_canisters_cached(
2415    spec: &WasmBuildSpec,
2416) -> Result<WasmBuildOutcome, WasmBuildError> {
2417    build_wasm_canisters_cached_internal(spec, &mut ProgressReporter::silent(), None)
2418}
2419
2420pub(super) fn build_wasm_canisters_cached_in_batch(
2421    spec: &WasmBuildSpec,
2422    index: usize,
2423    resolver: &mut WasmBuildBatchInputResolver<'_, '_>,
2424) -> WasmBuildBatchAttempt {
2425    let started = Instant::now();
2426    let mut progress = ProgressReporter::silent();
2427    let result = build_wasm_canisters_cached_internal(spec, &mut progress, Some((resolver, index)));
2428    if result
2429        .as_ref()
2430        .is_err_and(WasmBuildError::indicates_input_change)
2431    {
2432        resolver.invalidate_source_lease();
2433    }
2434    batch_attempt(result, &progress, started.elapsed())
2435}
2436
2437/// Build or reuse one exact Wasm set while streaming structured progress.
2438///
2439/// Cargo output remains captured for [`WasmBuildError::CommandFailed`] and is
2440/// additionally forwarded as raw chunks when enabled. Potentially long input
2441/// resolution, lock waits, maintenance, Cargo, and publication phases emit
2442/// periodic heartbeats, so a legitimate acquisition need not appear stalled.
2443/// Observer panics propagate after joining active phase work, terminating the
2444/// Cargo child when applicable, and preserving normal cleanup.
2445pub fn build_wasm_canisters_cached_with_progress<F>(
2446    spec: &WasmBuildSpec,
2447    config: WasmBuildProgressConfig,
2448    mut observer: F,
2449) -> Result<WasmBuildOutcome, WasmBuildError>
2450where
2451    F: FnMut(WasmBuildProgressEvent),
2452{
2453    if config.heartbeat_interval == Some(Duration::ZERO) {
2454        return Err(WasmBuildError::InvalidSpec {
2455            message: "Wasm build progress heartbeat interval must be greater than zero".to_owned(),
2456        });
2457    }
2458    build_wasm_canisters_cached_internal(
2459        spec,
2460        &mut ProgressReporter::observed(config, &mut observer),
2461        None,
2462    )
2463}
2464
2465pub(super) fn build_wasm_canisters_cached_in_batch_with_progress<F>(
2466    spec: &WasmBuildSpec,
2467    index: usize,
2468    resolver: &mut WasmBuildBatchInputResolver<'_, '_>,
2469    config: WasmBuildProgressConfig,
2470    mut observer: F,
2471) -> WasmBuildBatchAttempt
2472where
2473    F: FnMut(WasmBuildProgressEvent),
2474{
2475    if config.heartbeat_interval == Some(Duration::ZERO) {
2476        return WasmBuildBatchAttempt::invalid_spec(
2477            WasmBuildError::InvalidSpec {
2478                message: "Wasm build progress heartbeat interval must be greater than zero"
2479                    .to_owned(),
2480            },
2481            Duration::ZERO,
2482        );
2483    }
2484    let started = Instant::now();
2485    let mut progress = ProgressReporter::observed(config, &mut observer);
2486    let result = build_wasm_canisters_cached_internal(spec, &mut progress, Some((resolver, index)));
2487    if result
2488        .as_ref()
2489        .is_err_and(WasmBuildError::indicates_input_change)
2490    {
2491        resolver.invalidate_source_lease();
2492    }
2493    batch_attempt(result, &progress, started.elapsed())
2494}
2495
2496fn batch_attempt(
2497    result: Result<WasmBuildOutcome, WasmBuildError>,
2498    progress: &ProgressReporter<'_>,
2499    total: Duration,
2500) -> WasmBuildBatchAttempt {
2501    let (failure_phase, failure_timings) = result.as_ref().err().map_or((None, None), |error| {
2502        let (phase, timings) = progress.failure_details(error, total);
2503        (Some(phase), Some(timings))
2504    });
2505    WasmBuildBatchAttempt {
2506        result,
2507        failure_phase,
2508        failure_timings,
2509    }
2510}
2511
2512fn batch_source_assumptions(
2513    batch_resolution: Option<&(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2514) -> (bool, Option<Arc<RwLock<bool>>>) {
2515    batch_resolution.map_or((false, None), |(resolver, _)| {
2516        (
2517            resolver.assumes_sources_immutable(),
2518            resolver.prepared_invalidation(),
2519        )
2520    })
2521}
2522
2523fn build_wasm_canisters_cached_internal(
2524    spec: &WasmBuildSpec,
2525    progress: &mut ProgressReporter<'_>,
2526    mut batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2527) -> Result<WasmBuildOutcome, WasmBuildError> {
2528    let total_started = Instant::now();
2529    validate_spec(spec)?;
2530    let (assumes_sources_immutable, prepared_invalidation) =
2531        batch_source_assumptions(batch_resolution.as_ref());
2532    progress.emit(WasmBuildProgressEvent::Started);
2533    if spec.shared_incremental_maintenance_config.is_some() {
2534        let outcome = build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2535            spec,
2536            total_started,
2537            progress,
2538            batch_resolution.take(),
2539        )?;
2540        emit_finished_progress(&outcome, progress);
2541        return Ok(outcome);
2542    }
2543    let (cache_lock, first_lock_wait) =
2544        lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2545    ensure_cache_directory_tag(&spec.target_dir)?;
2546
2547    let resolved = resolve_initial_inputs(spec, batch_resolution.take(), progress)?;
2548    let isolated_acquisition =
2549        SharedIncrementalAcquisitionContext::isolated(prepared_invalidation.clone());
2550    if let Some(outcome) = try_reuse_wasm_artifacts(
2551        spec,
2552        &resolved,
2553        first_lock_wait,
2554        &isolated_acquisition,
2555        total_started,
2556        progress,
2557    )? {
2558        emit_finished_progress(&outcome, progress);
2559        return Ok(outcome);
2560    }
2561    progress.emit(WasmBuildProgressEvent::CacheMiss {
2562        fingerprint: resolved.fingerprint,
2563    });
2564
2565    let outcome = match &spec.cache_mode {
2566        WasmBuildCacheMode::Isolated => {
2567            let cache_entry = cache_entry_directory(spec, resolved.fingerprint);
2568            build_wasm_cache_miss(
2569                spec,
2570                resolved,
2571                first_lock_wait,
2572                isolated_acquisition,
2573                cache_entry,
2574                total_started,
2575                progress,
2576            )
2577        }
2578        WasmBuildCacheMode::SharedIncremental { .. } => {
2579            drop(cache_lock);
2580            build_wasm_with_shared_incremental(
2581                spec,
2582                resolved,
2583                first_lock_wait,
2584                assumes_sources_immutable,
2585                prepared_invalidation,
2586                total_started,
2587                progress,
2588            )
2589        }
2590    }?;
2591    emit_finished_progress(&outcome, progress);
2592    Ok(outcome)
2593}
2594
2595fn build_wasm_with_shared_incremental(
2596    spec: &WasmBuildSpec,
2597    resolved: ResolvedCargoBuildInputs,
2598    first_lock_wait: Duration,
2599    assumes_sources_immutable: bool,
2600    prepared_invalidation: Option<Arc<RwLock<bool>>>,
2601    total_started: Instant,
2602    progress: &mut ProgressReporter<'_>,
2603) -> Result<WasmBuildOutcome, WasmBuildError> {
2604    let configured_target = shared_incremental_target(spec)
2605        .expect("shared cache mode must resolve a shared Cargo target");
2606    progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted {
2607        target_dir: configured_target,
2608    });
2609    let (shared_lock, shared_lock_wait, shared_target) =
2610        lock_shared_incremental_target_with_progress(spec, progress)?;
2611    progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
2612        target_dir: shared_target.clone(),
2613        wait: shared_lock_wait,
2614    });
2615    let (_cache_lock, second_lock_wait) =
2616        lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2617    ensure_cache_directory_tag(&spec.target_dir)?;
2618
2619    let current = if assumes_sources_immutable {
2620        resolved
2621    } else {
2622        let mut current = resolve_inputs_with_progress(spec, progress)?;
2623        current.timings.include(resolved.timings);
2624        current
2625    };
2626    let lock_wait = first_lock_wait.saturating_add(second_lock_wait);
2627    let shared_incremental =
2628        SharedIncrementalAcquisitionContext::shared(shared_lock_wait, None, prepared_invalidation);
2629    if let Some(outcome) = try_reuse_wasm_artifacts(
2630        spec,
2631        &current,
2632        lock_wait,
2633        &shared_incremental,
2634        total_started,
2635        progress,
2636    )? {
2637        return Ok(outcome);
2638    }
2639
2640    let outcome = build_wasm_cache_miss(
2641        spec,
2642        current,
2643        lock_wait,
2644        shared_incremental,
2645        shared_target,
2646        total_started,
2647        progress,
2648    );
2649    drop(shared_lock);
2650    outcome
2651}
2652
2653fn build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2654    spec: &WasmBuildSpec,
2655    total_started: Instant,
2656    progress: &mut ProgressReporter<'_>,
2657    batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2658) -> Result<WasmBuildOutcome, WasmBuildError> {
2659    let (_, prepared_invalidation) = batch_source_assumptions(batch_resolution.as_ref());
2660    let configured_target = shared_incremental_target(spec)
2661        .expect("validated scheduled maintenance must have a shared Cargo target");
2662    progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted {
2663        target_dir: configured_target,
2664    });
2665    let (_shared_lock, shared_lock_wait, shared_target) =
2666        lock_shared_incremental_target_with_progress(spec, progress)?;
2667    progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
2668        target_dir: shared_target.clone(),
2669        wait: shared_lock_wait,
2670    });
2671    let (_cache_lock, lock_wait) = lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2672    ensure_cache_directory_tag(&spec.target_dir)?;
2673
2674    // Resolution under both locks proves the target boundary once for the
2675    // scheduled retention pass and the following exact-cache acquisition.
2676    let resolved = resolve_initial_inputs(spec, batch_resolution, progress)?;
2677    let shared_maintenance = perform_configured_shared_incremental_target_maintenance(
2678        spec,
2679        &shared_target,
2680        shared_lock_wait,
2681        progress,
2682    )?;
2683    let shared_incremental = SharedIncrementalAcquisitionContext::shared(
2684        shared_lock_wait,
2685        Some(shared_maintenance),
2686        prepared_invalidation,
2687    );
2688    if let Some(outcome) = try_reuse_wasm_artifacts(
2689        spec,
2690        &resolved,
2691        lock_wait,
2692        &shared_incremental,
2693        total_started,
2694        progress,
2695    )? {
2696        return Ok(outcome);
2697    }
2698    progress.emit(WasmBuildProgressEvent::CacheMiss {
2699        fingerprint: resolved.fingerprint,
2700    });
2701    build_wasm_cache_miss(
2702        spec,
2703        resolved,
2704        lock_wait,
2705        shared_incremental,
2706        shared_target,
2707        total_started,
2708        progress,
2709    )
2710}
2711
2712fn perform_configured_shared_incremental_target_maintenance(
2713    spec: &WasmBuildSpec,
2714    shared_target: &Path,
2715    lock_wait: Duration,
2716    progress: &mut ProgressReporter<'_>,
2717) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2718    let config = spec
2719        .shared_incremental_maintenance_config
2720        .expect("configured shared-target maintenance must have settings");
2721    progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceStarted {
2722        target_dir: shared_target.to_owned(),
2723    });
2724    let result = progress.run_phase(WasmBuildProgressPhase::SharedTargetMaintenance, || {
2725        let schedule = schedule_shared_incremental_target_maintenance(
2726            shared_target,
2727            config.policy,
2728            config.minimum_interval,
2729            lock_wait,
2730        )?;
2731        match schedule {
2732            SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => Ok(outcome),
2733            SharedIncrementalTargetMaintenanceSchedule::Due(due) => {
2734                perform_due_shared_incremental_target_maintenance(
2735                    shared_target,
2736                    config.policy,
2737                    lock_wait,
2738                    due,
2739                )
2740            }
2741        }
2742    });
2743    let outcome = integrated_shared_maintenance_result(config, shared_target, lock_wait, result)?;
2744    progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceFinished {
2745        outcome: outcome.clone(),
2746    });
2747    Ok(outcome)
2748}
2749
2750fn integrated_shared_maintenance_result(
2751    config: SharedIncrementalTargetMaintenanceConfig,
2752    shared_target: &Path,
2753    lock_wait: Duration,
2754    result: Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError>,
2755) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2756    match result {
2757        Ok(outcome) => Ok(outcome),
2758        Err(error)
2759            if config.failure_mode == SharedIncrementalTargetMaintenanceFailureMode::BestEffort =>
2760        {
2761            Ok(SharedIncrementalTargetMaintenanceOutcome::Failed {
2762                target_dir: shared_target.to_owned(),
2763                lock_wait,
2764                message: error.to_string(),
2765            })
2766        }
2767        Err(error) => Err(error),
2768    }
2769}
2770
2771fn resolve_inputs_with_progress(
2772    spec: &WasmBuildSpec,
2773    progress: &mut ProgressReporter<'_>,
2774) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2775    let resolved = build_fingerprint_with_progress(spec, progress)?;
2776    progress.emit(WasmBuildProgressEvent::InputsResolved {
2777        fingerprint: resolved.fingerprint,
2778        input_digest: resolved.input_digest,
2779        elapsed: resolved.timings.total,
2780    });
2781    Ok(resolved)
2782}
2783
2784fn resolve_initial_inputs(
2785    spec: &WasmBuildSpec,
2786    batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2787    progress: &mut ProgressReporter<'_>,
2788) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2789    let resolved = if let Some((resolver, index)) = batch_resolution {
2790        resolver.resolve(index, progress)?
2791    } else {
2792        build_fingerprint_with_progress(spec, progress)?
2793    };
2794    progress.emit(WasmBuildProgressEvent::InputsResolved {
2795        fingerprint: resolved.fingerprint,
2796        input_digest: resolved.input_digest,
2797        elapsed: resolved.timings.total,
2798    });
2799    Ok(resolved)
2800}
2801
2802fn emit_finished_progress(outcome: &WasmBuildOutcome, progress: &mut ProgressReporter<'_>) {
2803    let state = if outcome.is_reused() {
2804        progress.emit(WasmBuildProgressEvent::CacheHit {
2805            fingerprint: outcome.record().fingerprint,
2806        });
2807        WasmBuildProgressOutcome::Reused
2808    } else {
2809        WasmBuildProgressOutcome::Built
2810    };
2811    progress.emit(WasmBuildProgressEvent::Finished {
2812        outcome: state,
2813        fingerprint: outcome.record().fingerprint,
2814        elapsed: outcome.record().timings.total,
2815    });
2816}
2817
2818#[derive(Clone, Debug, Default)]
2819struct SharedIncrementalAcquisitionContext {
2820    lock_wait: Option<Duration>,
2821    maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
2822    prepared_invalidation: Option<Arc<RwLock<bool>>>,
2823}
2824
2825impl SharedIncrementalAcquisitionContext {
2826    fn isolated(prepared_invalidation: Option<Arc<RwLock<bool>>>) -> Self {
2827        Self {
2828            prepared_invalidation,
2829            ..Self::default()
2830        }
2831    }
2832
2833    const fn shared(
2834        lock_wait: Duration,
2835        maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
2836        prepared_invalidation: Option<Arc<RwLock<bool>>>,
2837    ) -> Self {
2838        Self {
2839            lock_wait: Some(lock_wait),
2840            maintenance,
2841            prepared_invalidation,
2842        }
2843    }
2844
2845    fn lock_prepared_publication(
2846        &self,
2847    ) -> Result<Option<std::sync::RwLockReadGuard<'_, bool>>, WasmBuildError> {
2848        let guard = self.prepared_invalidation.as_deref().map(|invalidation| {
2849            invalidation
2850                .read()
2851                .unwrap_or_else(std::sync::PoisonError::into_inner)
2852        });
2853        if guard.as_deref().is_some_and(|invalidated| *invalidated) {
2854            return Err(WasmBuildError::PreparedInputSnapshotInvalidated);
2855        }
2856        Ok(guard)
2857    }
2858}
2859
2860fn try_reuse_wasm_artifacts(
2861    spec: &WasmBuildSpec,
2862    resolved: &ResolvedCargoBuildInputs,
2863    lock_wait: Duration,
2864    shared_incremental: &SharedIncrementalAcquisitionContext,
2865    total_started: Instant,
2866    progress: &mut ProgressReporter<'_>,
2867) -> Result<Option<WasmBuildOutcome>, WasmBuildError> {
2868    let _publication_guard = shared_incremental.lock_prepared_publication()?;
2869    let fingerprint = resolved.fingerprint;
2870    let artifacts = expected_artifacts(spec, &spec.target_dir);
2871    let cache_entry = cache_entry_directory(spec, fingerprint);
2872    let artifacts_match = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2873        artifact_set_matches(&artifacts, fingerprint)
2874    });
2875    if artifacts_match {
2876        ensure_exact_cache_entry(spec, &artifacts, &cache_entry, fingerprint, progress)?;
2877        return Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
2878            spec,
2879            BuildRecordInput {
2880                fingerprint,
2881                input_digest: resolved.input_digest,
2882                lock_wait,
2883                shared_incremental: shared_incremental.clone(),
2884                input_resolution: resolved.timings,
2885                cargo_build: None,
2886                active_entry: &cache_entry,
2887            },
2888            total_started,
2889            progress,
2890        )?)));
2891    }
2892
2893    let cached_artifacts = expected_artifacts(spec, &cache_entry);
2894    let cached_artifacts_match = progress
2895        .run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2896            artifact_set_matches(&cached_artifacts, fingerprint)
2897        });
2898    if !cached_artifacts_match {
2899        return Ok(None);
2900    }
2901    progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2902        materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
2903        record_cache_entry_use(&cache_entry)
2904    })?;
2905    Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
2906        spec,
2907        BuildRecordInput {
2908            fingerprint,
2909            input_digest: resolved.input_digest,
2910            lock_wait,
2911            shared_incremental: shared_incremental.clone(),
2912            input_resolution: resolved.timings,
2913            cargo_build: None,
2914            active_entry: &cache_entry,
2915        },
2916        total_started,
2917        progress,
2918    )?)))
2919}
2920
2921fn ensure_exact_cache_entry(
2922    spec: &WasmBuildSpec,
2923    artifacts: &[PathBuf],
2924    cache_entry: &Path,
2925    fingerprint: InputDigest,
2926    progress: &mut ProgressReporter<'_>,
2927) -> Result<(), WasmBuildError> {
2928    let cached_artifacts = expected_artifacts(spec, cache_entry);
2929    let entry_is_current =
2930        progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2931            if artifact_set_matches(&cached_artifacts, fingerprint) {
2932                record_cache_entry_use(cache_entry)?;
2933                Ok::<_, WasmBuildError>(true)
2934            } else {
2935                Ok(false)
2936            }
2937        })?;
2938    if entry_is_current {
2939        return Ok(());
2940    }
2941    progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2942        remove_unretained_entry(cache_entry).map_err(wasm_cache_fs_error)?;
2943        create_dir_all(
2944            cache_entry,
2945            "create content-addressed Cargo target directory",
2946        )
2947    })?;
2948    let incomplete = IncompleteBuildDirectory::new(cache_entry.to_owned());
2949    let result = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2950        copy_wasm_artifacts(artifacts, &cached_artifacts)?;
2951        publish_artifact_stamps(&cached_artifacts, fingerprint)?;
2952        record_cache_entry_use(cache_entry)
2953    });
2954    match result {
2955        Ok(()) => {
2956            incomplete.preserve();
2957            Ok(())
2958        }
2959        Err(build_error) => Err(cleanup_failed_fingerprint_build(
2960            build_error,
2961            incomplete,
2962            progress,
2963        )),
2964    }
2965}
2966
2967fn build_wasm_cache_miss(
2968    spec: &WasmBuildSpec,
2969    resolved: ResolvedCargoBuildInputs,
2970    lock_wait: Duration,
2971    shared_incremental: SharedIncrementalAcquisitionContext,
2972    cargo_target_dir: PathBuf,
2973    total_started: Instant,
2974    progress: &mut ProgressReporter<'_>,
2975) -> Result<WasmBuildOutcome, WasmBuildError> {
2976    let fingerprint = resolved.fingerprint;
2977    let mut input_resolution = resolved.timings;
2978    let artifacts = expected_artifacts(spec, &spec.target_dir);
2979    let cache_entry = cache_entry_directory(spec, fingerprint);
2980    let preparation_started = Instant::now();
2981    progress.begin_phase(WasmBuildFailurePhase::ArtifactPublication);
2982    let preparation_result = (|| {
2983        remove_unretained_entry(&cache_entry).map_err(wasm_cache_fs_error)?;
2984        create_dir_all(
2985            &cache_entry,
2986            "create content-addressed Cargo target directory",
2987        )
2988    })();
2989    progress.record_phase(
2990        WasmBuildFailurePhase::ArtifactPublication,
2991        preparation_started.elapsed(),
2992    );
2993    preparation_result?;
2994    let incomplete_directory = IncompleteBuildDirectory::new(cache_entry.clone());
2995    let build_result = (|| {
2996        if matches!(
2997            spec.cache_mode,
2998            WasmBuildCacheMode::SharedIncremental { .. }
2999        ) {
3000            record_cache_entry_use(&cargo_target_dir)?;
3001        }
3002        let build_started = Instant::now();
3003        progress.begin_phase(WasmBuildFailurePhase::CargoBuild);
3004        let cargo_result = run_cargo_build(spec, &cargo_target_dir, progress);
3005        let cargo_build = build_started.elapsed();
3006        progress.record_phase(WasmBuildFailurePhase::CargoBuild, cargo_build);
3007        cargo_result?;
3008        let built_artifacts = expected_artifacts(spec, &cargo_target_dir);
3009        let validation_started = Instant::now();
3010        progress.begin_phase(WasmBuildFailurePhase::ArtifactPublication);
3011        let missing = missing_artifacts(&built_artifacts);
3012        progress.record_phase(
3013            WasmBuildFailurePhase::ArtifactPublication,
3014            validation_started.elapsed(),
3015        );
3016        if !missing.is_empty() {
3017            return Err(WasmBuildError::MissingArtifacts { paths: missing });
3018        }
3019
3020        let verified = resolve_inputs_with_progress(spec, progress)?;
3021        input_resolution.include(verified.timings);
3022        if resolved.validation_digest != verified.validation_digest {
3023            return Err(WasmBuildError::InputsChangedDuringBuild {
3024                before: resolved.validation_digest,
3025                after: verified.validation_digest,
3026            });
3027        }
3028        if fingerprint != verified.fingerprint {
3029            return Err(WasmBuildError::InputsChangedDuringBuild {
3030                before: fingerprint,
3031                after: verified.fingerprint,
3032            });
3033        }
3034
3035        // Publication is the prepared snapshot's linearization boundary. A
3036        // reader that reaches it first may finish publishing; invalidation
3037        // takes the write side of this lock and therefore precedes every later
3038        // reader without racing a successful stamp into existence.
3039        let publication_guard = shared_incremental.lock_prepared_publication()?;
3040
3041        let cached_artifacts = expected_artifacts(spec, &cache_entry);
3042        progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3043            if cargo_target_dir != cache_entry {
3044                copy_wasm_artifacts(&built_artifacts, &cached_artifacts)?;
3045            }
3046            publish_artifact_stamps(&cached_artifacts, fingerprint)?;
3047            materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
3048            record_cache_entry_use(&cache_entry)
3049        })?;
3050        drop(publication_guard);
3051
3052        Ok(WasmBuildOutcome::Built(complete_build_record(
3053            spec,
3054            BuildRecordInput {
3055                fingerprint,
3056                input_digest: resolved.input_digest,
3057                lock_wait,
3058                shared_incremental,
3059                input_resolution,
3060                cargo_build: Some(cargo_build),
3061                active_entry: &cache_entry,
3062            },
3063            total_started,
3064            progress,
3065        )?))
3066    })();
3067    finish_fingerprint_build(build_result, incomplete_directory, progress)
3068}
3069
3070/// Prune fingerprint-specific Cargo target directories under `target_dir`.
3071///
3072/// Pruning uses the same exclusive process lock as builds. Entries older than
3073/// the configured age are removed first, then least-recently-used entries are
3074/// removed until the configured logical byte limit is met. Only direct child
3075/// directories with SHA-256 fingerprint names are eligible; caller-facing
3076/// artifacts and unrelated target contents are never removed.
3077/// Entries owned by live build records are skipped until their last owner drops.
3078pub fn prune_wasm_build_cache(
3079    target_dir: &Path,
3080    policy: ArtifactCachePrunePolicy,
3081) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3082    let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
3083    ensure_cache_directory_tag(target_dir)?;
3084
3085    prune_wasm_build_cache_locked(target_dir, policy, None)
3086}
3087
3088struct BuildRecordInput<'a> {
3089    fingerprint: InputDigest,
3090    input_digest: InputDigest,
3091    lock_wait: Duration,
3092    shared_incremental: SharedIncrementalAcquisitionContext,
3093    input_resolution: WasmInputResolutionTimings,
3094    cargo_build: Option<Duration>,
3095    active_entry: &'a Path,
3096}
3097
3098fn complete_build_record(
3099    spec: &WasmBuildSpec,
3100    input: BuildRecordInput<'_>,
3101    total_started: Instant,
3102    progress: &mut ProgressReporter<'_>,
3103) -> Result<WasmBuildRecord, WasmBuildError> {
3104    let retention = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3105        RetainedCacheEntry::acquire(input.active_entry).map_err(wasm_cache_fs_error)
3106    })?;
3107    let (maintenance, cache_maintenance) = spec.prune_policy.map_or((None, None), |policy| {
3108        progress.run_phase(WasmBuildProgressPhase::ExactCacheMaintenance, || {
3109            let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
3110            let identity = policy.maintenance_identity();
3111            perform_scheduled_cache_maintenance(&cache_root, spec.prune_interval, &identity, || {
3112                prune_wasm_build_cache_locked(&spec.target_dir, policy, Some(input.active_entry))
3113                    .map_err(|error| error.to_string())
3114            })
3115        })
3116    });
3117    Ok(WasmBuildRecord {
3118        fingerprint: input.fingerprint,
3119        input_digest: input.input_digest,
3120        exact_cache_path: input.active_entry.to_owned(),
3121        artifacts: expected_artifacts(spec, input.active_entry),
3122        _retention: retention,
3123        timings: WasmBuildTimings {
3124            lock_wait: input.lock_wait,
3125            shared_incremental_lock_wait: input.shared_incremental.lock_wait,
3126            input_resolution: input.input_resolution,
3127            cargo_build: input.cargo_build,
3128            cache_maintenance,
3129            total: total_started.elapsed(),
3130        },
3131        maintenance,
3132        shared_incremental_maintenance: input.shared_incremental.maintenance,
3133    })
3134}
3135
3136fn prune_wasm_build_cache_locked(
3137    target_dir: &Path,
3138    policy: ArtifactCachePrunePolicy,
3139    protected_entry: Option<&Path>,
3140) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3141    let cache_root = target_dir.join(".ic-testkit/wasm-targets");
3142    prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
3143        .map_err(wasm_cache_fs_error)
3144}
3145
3146struct IncompleteBuildDirectory {
3147    path: PathBuf,
3148    armed: bool,
3149}
3150
3151impl IncompleteBuildDirectory {
3152    const fn new(path: PathBuf) -> Self {
3153        Self { path, armed: true }
3154    }
3155
3156    fn preserve(mut self) {
3157        self.armed = false;
3158    }
3159
3160    fn cleanup(mut self) -> io::Result<()> {
3161        let result = remove_path_if_present(&self.path);
3162        if result.is_ok() {
3163            self.armed = false;
3164        }
3165        result
3166    }
3167}
3168
3169impl Drop for IncompleteBuildDirectory {
3170    fn drop(&mut self) {
3171        if self.armed {
3172            let _ = remove_path_if_present(&self.path);
3173        }
3174    }
3175}
3176
3177fn finish_fingerprint_build(
3178    result: Result<WasmBuildOutcome, WasmBuildError>,
3179    incomplete_directory: IncompleteBuildDirectory,
3180    progress: &mut ProgressReporter<'_>,
3181) -> Result<WasmBuildOutcome, WasmBuildError> {
3182    match result {
3183        Ok(outcome) => {
3184            incomplete_directory.preserve();
3185            Ok(outcome)
3186        }
3187        Err(build_error) => Err(cleanup_failed_fingerprint_build(
3188            build_error,
3189            incomplete_directory,
3190            progress,
3191        )),
3192    }
3193}
3194
3195fn cleanup_failed_fingerprint_build(
3196    build_error: WasmBuildError,
3197    incomplete_directory: IncompleteBuildDirectory,
3198    progress: &mut ProgressReporter<'_>,
3199) -> WasmBuildError {
3200    let path = incomplete_directory.path.clone();
3201    let primary_phase = progress.failure_phase;
3202    let cleanup_started = Instant::now();
3203    let cleanup = incomplete_directory.cleanup();
3204    progress.record_phase(WasmBuildFailurePhase::Cleanup, cleanup_started.elapsed());
3205    match cleanup {
3206        Ok(()) => {
3207            progress.failure_phase = primary_phase;
3208            build_error
3209        }
3210        Err(source) => WasmBuildError::FailedBuildCleanup {
3211            build_error: Box::new(build_error),
3212            path,
3213            source,
3214        },
3215    }
3216}
3217
3218fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
3219    create_dir_all(target_dir, "create Cargo target directory")?;
3220    let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3221    lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)
3222}
3223
3224fn lock_wasm_build_cache_with_progress(
3225    target_dir: &Path,
3226    progress: &mut ProgressReporter<'_>,
3227) -> Result<(File, Duration), WasmBuildError> {
3228    progress.begin_phase(WasmBuildFailurePhase::ExactCacheCoordination);
3229    create_dir_all(target_dir, "create Cargo target directory")?;
3230    let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3231    lock_cache_file_with_progress(&lock_path, WasmBuildProgressPhase::ExactCacheLock, progress)
3232}
3233
3234fn lock_shared_incremental_target(
3235    spec: &WasmBuildSpec,
3236) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3237    lock_shared_incremental_target_internal(spec, None)
3238}
3239
3240fn lock_shared_incremental_target_with_progress(
3241    spec: &WasmBuildSpec,
3242    progress: &mut ProgressReporter<'_>,
3243) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3244    lock_shared_incremental_target_internal(spec, Some(progress))
3245}
3246
3247fn lock_shared_incremental_target_internal(
3248    spec: &WasmBuildSpec,
3249    mut progress: Option<&mut ProgressReporter<'_>>,
3250) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3251    if let Some(progress) = progress.as_deref_mut() {
3252        progress.begin_phase(WasmBuildFailurePhase::SharedTargetCoordination);
3253    }
3254    let target_dir =
3255        shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
3256            message: "shared incremental target is not configured".to_owned(),
3257        })?;
3258    create_dir_all(
3259        &target_dir,
3260        "create shared incremental Cargo target directory",
3261    )?;
3262    ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
3263    let canonical = target_dir
3264        .canonicalize()
3265        .map_err(|source| WasmBuildError::Io {
3266            operation: "resolve shared incremental Cargo target directory",
3267            path: target_dir.clone(),
3268            source,
3269        })?;
3270    let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
3271    let (lock, wait) = if let Some(progress) = progress {
3272        lock_cache_file_with_progress(
3273            &lock_path,
3274            WasmBuildProgressPhase::SharedTargetLock,
3275            progress,
3276        )?
3277    } else {
3278        lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)?
3279    };
3280    Ok((lock, wait, canonical))
3281}
3282
3283fn lock_cache_file_with_progress(
3284    lock_path: &Path,
3285    phase: WasmBuildProgressPhase,
3286    progress: &mut ProgressReporter<'_>,
3287) -> Result<(File, Duration), WasmBuildError> {
3288    let failure_phase = progress_failure_phase(phase);
3289    let started = Instant::now();
3290    progress.begin_phase(failure_phase);
3291    let result = if !progress.is_observed() || progress.config.heartbeat_interval.is_none() {
3292        lock_cache_file(lock_path).map_err(wasm_cache_fs_error)
3293    } else {
3294        let heartbeat_interval = progress
3295            .config
3296            .heartbeat_interval
3297            .expect("observed cache lock must have a heartbeat interval");
3298        lock_cache_file_with_wait_observer(lock_path, heartbeat_interval, |elapsed| {
3299            progress.emit_heartbeat_if_due(phase, elapsed);
3300        })
3301        .map_err(wasm_cache_fs_error)
3302    };
3303    progress.record_phase(failure_phase, started.elapsed());
3304    result
3305}
3306
3307fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
3308    ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
3309}
3310
3311fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
3312    record_entry_use(path).map_err(wasm_cache_fs_error)
3313}
3314
3315fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
3316    WasmBuildError::Io {
3317        operation: error.operation,
3318        path: error.path,
3319        source: error.source,
3320    }
3321}
3322
3323fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3324    if spec.packages.is_empty() {
3325        return Err(WasmBuildError::InvalidSpec {
3326            message: "at least one Cargo package is required".to_owned(),
3327        });
3328    }
3329    if spec.profile_target_dir.is_empty() {
3330        return Err(WasmBuildError::InvalidSpec {
3331            message: "Cargo profile target directory must not be empty".to_owned(),
3332        });
3333    }
3334    if spec.target.is_empty() {
3335        return Err(WasmBuildError::InvalidSpec {
3336            message: "Cargo compilation target must not be empty".to_owned(),
3337        });
3338    }
3339    if matches!(
3340        &spec.cache_mode,
3341        WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
3342    ) {
3343        return Err(WasmBuildError::InvalidSpec {
3344            message: "shared incremental Cargo target directory must not be empty".to_owned(),
3345        });
3346    }
3347    if spec.shared_incremental_maintenance_config.is_some()
3348        && !matches!(
3349            spec.cache_mode,
3350            WasmBuildCacheMode::SharedIncremental { .. }
3351        )
3352    {
3353        return Err(WasmBuildError::InvalidSpec {
3354            message:
3355                "scheduled shared-target maintenance requires a shared incremental Cargo target"
3356                    .to_owned(),
3357        });
3358    }
3359    Ok(())
3360}
3361
3362fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3363    build_fingerprint_with_progress(spec, &mut ProgressReporter::silent())
3364}
3365
3366fn build_fingerprint_with_progress(
3367    spec: &WasmBuildSpec,
3368    progress: &mut ProgressReporter<'_>,
3369) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3370    let total_started = Instant::now();
3371    let tool_started = Instant::now();
3372    let cargo_identity = progress.run_phase(WasmBuildProgressPhase::CargoIdentity, || {
3373        command_identity(
3374            spec,
3375            WasmBuildPhase::CargoIdentity,
3376            &spec.cargo_program,
3377            &["--version", "--verbose"],
3378        )
3379    })?;
3380    let rustc_program = spec
3381        .extra_env
3382        .get(OsStr::new("RUSTC"))
3383        .unwrap_or(&spec.rustc_program);
3384    let rustc_identity = progress.run_phase(WasmBuildProgressPhase::RustcIdentity, || {
3385        command_identity(spec, WasmBuildPhase::RustcIdentity, rustc_program, &["-vV"])
3386    })?;
3387    let tool_identity = tool_started.elapsed();
3388
3389    let metadata_started = Instant::now();
3390    let metadata = progress.run_phase(WasmBuildProgressPhase::CargoMetadata, || {
3391        cargo_metadata(spec)
3392    })?;
3393    let cargo_metadata = metadata_started.elapsed();
3394
3395    let discovery_started = Instant::now();
3396    let (inputs, exclusions) =
3397        progress.run_phase(WasmBuildProgressPhase::InputDiscovery, || {
3398            let inputs = resolve_local_inputs(spec, &metadata)?;
3399            validate_shared_incremental_target_boundary(spec, &inputs.validation_inputs)?;
3400            let exclusions = source_exclusions(spec, &inputs.validation_inputs);
3401            Ok::<_, WasmBuildError>((inputs, exclusions))
3402        })?;
3403    let input_discovery = discovery_started.elapsed();
3404
3405    let hashing_started = Instant::now();
3406    let (input_digest, validation_digest) =
3407        progress.run_phase(WasmBuildProgressPhase::ContentHashing, || {
3408            let mut cache = LabeledPathDigestCache::default();
3409            digest_resolved_local_inputs(
3410                &inputs,
3411                &exclusions,
3412                &mut cache,
3413                &spec.workspace_root,
3414                "hash Wasm build inputs",
3415                "hash semantic Wasm build inputs",
3416            )
3417        })?;
3418    let content_hashing = hashing_started.elapsed();
3419
3420    let fingerprint =
3421        finish_build_fingerprint(spec, &cargo_identity, &rustc_identity, input_digest);
3422    Ok(ResolvedCargoBuildInputs {
3423        fingerprint,
3424        input_digest,
3425        validation_digest,
3426        inputs: inputs
3427            .validation_inputs
3428            .into_iter()
3429            .map(|(label, path)| CargoBuildInput { label, path })
3430            .collect(),
3431        exclusions,
3432        timings: WasmInputResolutionTimings {
3433            tool_identity,
3434            cargo_metadata,
3435            input_discovery,
3436            content_hashing,
3437            total: total_started.elapsed(),
3438        },
3439    })
3440}
3441
3442fn finish_build_fingerprint(
3443    spec: &WasmBuildSpec,
3444    cargo_identity: &[u8],
3445    rustc_identity: &[u8],
3446    input_digest: InputDigest,
3447) -> InputDigest {
3448    let mut hasher = InputHasher::new(CACHE_FORMAT_VERSION);
3449    let mut packages = spec.packages.clone();
3450    packages.sort();
3451    packages.dedup();
3452    for package in packages {
3453        hasher.field("package", package.as_bytes());
3454    }
3455    hasher.field("target", spec.target.as_bytes());
3456    hasher.field("profile-target-dir", spec.profile_target_dir.as_bytes());
3457    for argument in &spec.cargo_profile_args {
3458        hasher.field("cargo-argument", &os_bytes(argument));
3459    }
3460    for (key, value) in effective_environment(spec) {
3461        hasher.field("environment-key", &os_bytes(&key));
3462        if let Some(value) = value {
3463            hasher.field("environment-value", &os_bytes(&value));
3464        } else {
3465            hasher.field("environment-unset", b"");
3466        }
3467    }
3468    hasher.field("cargo-identity", cargo_identity);
3469    hasher.field("rustc-identity", rustc_identity);
3470    hasher.field("source-input-digest", input_digest.as_bytes());
3471    hasher.finish()
3472}
3473
3474fn command_identity(
3475    spec: &WasmBuildSpec,
3476    phase: WasmBuildPhase,
3477    program: &OsStr,
3478    arguments: &[&str],
3479) -> Result<Vec<u8>, WasmBuildError> {
3480    let mut command = Command::new(program);
3481    command.current_dir(&spec.workspace_root).args(arguments);
3482    apply_command_environment(&mut command, spec);
3483    let output = command
3484        .output()
3485        .map_err(|source| WasmBuildError::CommandSpawn {
3486            phase,
3487            program: program.to_owned(),
3488            source,
3489        })?;
3490    ensure_command_success(phase, output).map(|output| {
3491        let mut identity = output.stdout;
3492        identity.extend_from_slice(&output.stderr);
3493        identity
3494    })
3495}
3496
3497fn cargo_metadata(spec: &WasmBuildSpec) -> Result<Value, WasmBuildError> {
3498    let mut command = Command::new(&spec.cargo_program);
3499    command
3500        .current_dir(&spec.workspace_root)
3501        .args(["metadata", "--format-version", "1"]);
3502    for argument in metadata_arguments(&spec.cargo_profile_args) {
3503        command.arg(argument);
3504    }
3505    apply_command_environment(&mut command, spec);
3506    let output = command
3507        .output()
3508        .map_err(|source| WasmBuildError::CommandSpawn {
3509            phase: WasmBuildPhase::CargoMetadata,
3510            program: spec.cargo_program.clone(),
3511            source,
3512        })?;
3513    let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
3514    serde_json::from_slice(&output.stdout).map_err(|error| WasmBuildError::InvalidMetadata {
3515        message: format!("Cargo metadata was not valid JSON: {error}"),
3516    })
3517}
3518
3519fn metadata_arguments(arguments: &[OsString]) -> Vec<OsString> {
3520    let mut selected = Vec::new();
3521    let mut arguments = arguments.iter();
3522    while let Some(argument) = arguments.next() {
3523        let argument_text = argument.to_string_lossy();
3524        match argument_text.as_ref() {
3525            "--all-features" | "--no-default-features" | "--locked" | "--offline" | "--frozen" => {
3526                selected.push(argument.clone());
3527            }
3528            "--features" | "-F" | "--filter-platform" => {
3529                selected.push(argument.clone());
3530                if let Some(value) = arguments.next() {
3531                    selected.push(value.clone());
3532                }
3533            }
3534            _ if argument_text.starts_with("--features=")
3535                || argument_text.starts_with("--filter-platform=") =>
3536            {
3537                selected.push(argument.clone());
3538            }
3539            _ => {}
3540        }
3541    }
3542    selected
3543}
3544
3545#[derive(Clone)]
3546struct MetadataPackage {
3547    id: String,
3548    name: String,
3549    version: String,
3550    manifest_path: PathBuf,
3551    is_local: bool,
3552    source: Option<String>,
3553    semantic_fields: Vec<(&'static str, Option<String>)>,
3554}
3555
3556const SEMANTIC_PACKAGE_FIELDS: &[&str] = &[
3557    "authors",
3558    "default_run",
3559    "description",
3560    "documentation",
3561    "edition",
3562    "homepage",
3563    "license",
3564    "license_file",
3565    "links",
3566    "metadata",
3567    "name",
3568    "readme",
3569    "repository",
3570    "rust_version",
3571    "version",
3572];
3573
3574struct LockedPackageIdentity {
3575    name: String,
3576    version: String,
3577    source: String,
3578    checksum: Option<String>,
3579}
3580
3581fn resolve_local_inputs(
3582    spec: &WasmBuildSpec,
3583    metadata: &Value,
3584) -> Result<ResolvedLocalInputs, WasmBuildError> {
3585    let packages = metadata_packages(metadata)?;
3586    let mut selected_ids = selected_package_ids(spec, metadata, &packages)?;
3587    let dependencies = metadata_dependencies(metadata)?;
3588    let mut closure = BTreeSet::new();
3589    while let Some(id) = selected_ids.pop_front() {
3590        if !closure.insert(id.clone()) {
3591            continue;
3592        }
3593        if let Some(deps) = dependencies.get(&id) {
3594            selected_ids.extend(deps.iter().cloned());
3595        }
3596    }
3597
3598    let workspace_root = metadata
3599        .get("workspace_root")
3600        .and_then(Value::as_str)
3601        .map_or_else(|| spec.workspace_root.clone(), PathBuf::from);
3602    let projection = semantic_workspace_projection(metadata, &packages, &closure, &workspace_root)?;
3603    let mut validation_inputs = workspace_configuration_inputs(spec, &workspace_root)?;
3604    append_package_inputs(&mut validation_inputs, &packages, closure, &workspace_root)?;
3605    append_additional_inputs(&mut validation_inputs, spec, &workspace_root);
3606    let fingerprint = projection.map_or(LocalInputFingerprint::Conservative, |workspace| {
3607        LocalInputFingerprint::Projected {
3608            inputs: validation_inputs
3609                .iter()
3610                .filter(|(label, _)| !is_broad_workspace_input(label))
3611                .cloned()
3612                .collect(),
3613            workspace,
3614        }
3615    });
3616    Ok(ResolvedLocalInputs {
3617        validation_inputs,
3618        fingerprint,
3619    })
3620}
3621
3622fn metadata_packages(metadata: &Value) -> Result<HashMap<String, MetadataPackage>, WasmBuildError> {
3623    let packages_value = metadata
3624        .get("packages")
3625        .and_then(Value::as_array)
3626        .ok_or_else(|| invalid_metadata("Cargo metadata has no package array"))?;
3627    let mut packages = HashMap::new();
3628    for value in packages_value {
3629        let source = optional_string(value, "source")?;
3630        let package = MetadataPackage {
3631            id: required_string(value, "id")?,
3632            name: required_string(value, "name")?,
3633            version: required_string(value, "version")?,
3634            manifest_path: PathBuf::from(required_string(value, "manifest_path")?),
3635            is_local: value.get("source").is_some_and(Value::is_null),
3636            source,
3637            semantic_fields: SEMANTIC_PACKAGE_FIELDS
3638                .iter()
3639                .map(|field| (*field, value.get(*field).map(Value::to_string)))
3640                .collect(),
3641        };
3642        packages.insert(package.id.clone(), package);
3643    }
3644    Ok(packages)
3645}
3646
3647fn selected_package_ids(
3648    spec: &WasmBuildSpec,
3649    metadata: &Value,
3650    packages: &HashMap<String, MetadataPackage>,
3651) -> Result<VecDeque<String>, WasmBuildError> {
3652    let workspace_members = metadata
3653        .get("workspace_members")
3654        .and_then(Value::as_array)
3655        .ok_or_else(|| invalid_metadata("Cargo metadata has no workspace member array"))?
3656        .iter()
3657        .filter_map(Value::as_str)
3658        .collect::<HashSet<_>>();
3659    let mut selected_ids = VecDeque::new();
3660    for requested in &spec.packages {
3661        let matches = packages
3662            .values()
3663            .filter(|package| {
3664                package.name == *requested && workspace_members.contains(package.id.as_str())
3665            })
3666            .map(|package| package.id.clone())
3667            .collect::<Vec<_>>();
3668        match matches.as_slice() {
3669            [id] => selected_ids.push_back(id.clone()),
3670            [] => {
3671                return Err(WasmBuildError::InvalidSpec {
3672                    message: format!("Cargo workspace contains no package named `{requested}`"),
3673                });
3674            }
3675            _ => {
3676                return Err(WasmBuildError::InvalidSpec {
3677                    message: format!("Cargo workspace package name `{requested}` is ambiguous"),
3678                });
3679            }
3680        }
3681    }
3682    Ok(selected_ids)
3683}
3684
3685fn metadata_dependencies(metadata: &Value) -> Result<HashMap<String, Vec<String>>, WasmBuildError> {
3686    let mut dependencies = HashMap::<String, Vec<String>>::new();
3687    let nodes = metadata
3688        .pointer("/resolve/nodes")
3689        .and_then(Value::as_array)
3690        .ok_or_else(|| invalid_metadata("Cargo metadata has no resolved dependency nodes"))?;
3691    for node in nodes {
3692        let id = required_string(node, "id")?;
3693        let deps = node
3694            .get("deps")
3695            .and_then(Value::as_array)
3696            .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
3697            .iter()
3698            .map(|dependency| required_string(dependency, "pkg"))
3699            .collect::<Result<Vec<_>, _>>()?;
3700        dependencies.insert(id, deps);
3701    }
3702    Ok(dependencies)
3703}
3704
3705fn semantic_workspace_projection(
3706    metadata: &Value,
3707    packages: &HashMap<String, MetadataPackage>,
3708    closure: &BTreeSet<String>,
3709    workspace_root: &Path,
3710) -> Result<Option<InputDigest>, WasmBuildError> {
3711    // A workspace-root or external local package cannot be separated from the
3712    // broad root safely; `None` keeps the complete-input fingerprint.
3713    let locked_packages = locked_package_identities(workspace_root)?;
3714    let mut identities = HashMap::new();
3715    for id in closure {
3716        let package = packages
3717            .get(id)
3718            .ok_or_else(|| invalid_metadata(&format!("resolved package `{id}` is missing")))?;
3719        let Some(identity) = semantic_package_identity(package, workspace_root, &locked_packages)
3720        else {
3721            return Ok(None);
3722        };
3723        identities.insert(id.as_str(), identity);
3724    }
3725
3726    let nodes = metadata
3727        .pointer("/resolve/nodes")
3728        .and_then(Value::as_array)
3729        .ok_or_else(|| invalid_metadata("Cargo metadata has no resolved dependency nodes"))?;
3730    let nodes_by_id = nodes
3731        .iter()
3732        .map(|node| Ok((required_string(node, "id")?, node)))
3733        .collect::<Result<HashMap<_, _>, WasmBuildError>>()?;
3734    let mut projected_packages = closure
3735        .iter()
3736        .map(|id| {
3737            let package = packages
3738                .get(id)
3739                .expect("selected package closure was validated above");
3740            let identity = identities[id.as_str()];
3741            let node = nodes_by_id.get(id).copied().ok_or_else(|| {
3742                invalid_metadata(&format!("resolved package `{id}` has no dependency node"))
3743            })?;
3744            let projection = semantic_package_projection(package, node, &identities)?;
3745            Ok::<_, WasmBuildError>((identity, projection))
3746        })
3747        .collect::<Result<Vec<_>, _>>()?;
3748    projected_packages.sort_by_key(|(identity, _)| *identity);
3749
3750    let root_manifest = workspace_root.join("Cargo.toml");
3751    let root_contents =
3752        fs::read_to_string(&root_manifest).map_err(|source| WasmBuildError::Io {
3753            operation: "read workspace manifest for semantic projection",
3754            path: root_manifest.clone(),
3755            source,
3756        })?;
3757    let root = toml::from_str::<TomlValue>(&root_contents).map_err(|error| {
3758        invalid_metadata(&format!(
3759            "workspace manifest could not be projected as TOML: {error}"
3760        ))
3761    })?;
3762
3763    let mut hasher = InputHasher::new("wasm-semantic-workspace-projection-v1");
3764    for (identity, projection) in projected_packages {
3765        hasher.field("package-identity", identity.as_bytes());
3766        hasher.field("package-projection", projection.as_bytes());
3767    }
3768    hash_toml_setting(&mut hasher, "cargo-features", root.get("cargo-features"));
3769    hash_toml_setting(&mut hasher, "profile", root.get("profile"));
3770    let workspace = root.get("workspace").and_then(TomlValue::as_table);
3771    hash_toml_setting(
3772        &mut hasher,
3773        "workspace-resolver",
3774        workspace.and_then(|table| table.get("resolver")),
3775    );
3776    hash_toml_setting(
3777        &mut hasher,
3778        "workspace-lints",
3779        workspace.and_then(|table| table.get("lints")),
3780    );
3781    Ok(Some(hasher.finish()))
3782}
3783
3784fn locked_package_identities(
3785    workspace_root: &Path,
3786) -> Result<Vec<LockedPackageIdentity>, WasmBuildError> {
3787    let lockfile = workspace_root.join("Cargo.lock");
3788    let contents = match fs::read_to_string(&lockfile) {
3789        Ok(contents) => contents,
3790        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
3791        Err(source) => {
3792            return Err(WasmBuildError::Io {
3793                operation: "read Cargo lockfile for semantic projection",
3794                path: lockfile,
3795                source,
3796            });
3797        }
3798    };
3799    let lock = toml::from_str::<TomlValue>(&contents).map_err(|error| {
3800        invalid_metadata(&format!(
3801            "Cargo lockfile could not be projected as TOML: {error}"
3802        ))
3803    })?;
3804    let Some(packages) = lock.get("package").and_then(TomlValue::as_array) else {
3805        return Ok(Vec::new());
3806    };
3807    packages
3808        .iter()
3809        .filter_map(|package| {
3810            let Some(table) = package.as_table() else {
3811                return Some(Err(invalid_metadata(
3812                    "Cargo lockfile package entry is not a table",
3813                )));
3814            };
3815            let source = table.get("source")?.as_str().map(str::to_owned);
3816            Some(
3817                source
3818                    .ok_or_else(|| {
3819                        invalid_metadata("Cargo lockfile package source is not a string")
3820                    })
3821                    .and_then(|source| {
3822                        Ok(LockedPackageIdentity {
3823                            name: required_toml_string(table, "name", "Cargo lockfile package")?,
3824                            version: required_toml_string(
3825                                table,
3826                                "version",
3827                                "Cargo lockfile package",
3828                            )?,
3829                            source,
3830                            checksum: optional_toml_string(
3831                                table,
3832                                "checksum",
3833                                "Cargo lockfile package",
3834                            )?,
3835                        })
3836                    }),
3837            )
3838        })
3839        .collect()
3840}
3841
3842fn required_toml_string(
3843    table: &toml::Table,
3844    field: &str,
3845    context: &str,
3846) -> Result<String, WasmBuildError> {
3847    table
3848        .get(field)
3849        .and_then(TomlValue::as_str)
3850        .map(str::to_owned)
3851        .ok_or_else(|| invalid_metadata(&format!("{context} `{field}` is missing or not a string")))
3852}
3853
3854fn optional_toml_string(
3855    table: &toml::Table,
3856    field: &str,
3857    context: &str,
3858) -> Result<Option<String>, WasmBuildError> {
3859    match table.get(field) {
3860        None => Ok(None),
3861        Some(TomlValue::String(value)) => Ok(Some(value.clone())),
3862        Some(_) => Err(invalid_metadata(&format!(
3863            "{context} `{field}` is not a string"
3864        ))),
3865    }
3866}
3867
3868fn semantic_package_identity(
3869    package: &MetadataPackage,
3870    workspace_root: &Path,
3871    locked_packages: &[LockedPackageIdentity],
3872) -> Option<InputDigest> {
3873    let mut hasher = InputHasher::new("wasm-semantic-package-identity-v1");
3874    hasher.field("name", package.name.as_bytes());
3875    hasher.field("version", package.version.as_bytes());
3876    if package.is_local {
3877        let manifest = package.manifest_path.strip_prefix(workspace_root).ok()?;
3878        let package_root = package.manifest_path.parent()?;
3879        if package_root == workspace_root {
3880            return None;
3881        }
3882        hasher.field("local-manifest", &os_bytes(manifest.as_os_str()));
3883    } else {
3884        let metadata_source = package.source.as_deref()?;
3885        let locked = locked_packages.iter().find(|locked| {
3886            locked.name == package.name
3887                && locked.version == package.version
3888                && locked.source == metadata_source
3889        })?;
3890        match locked.source.as_str() {
3891            source if source.starts_with("registry+") && locked.checksum.is_some() => {}
3892            source if source.starts_with("git+") && source.contains('#') => {}
3893            _ => return None,
3894        }
3895        hasher.field("external-package-id", package.id.as_bytes());
3896        hasher.field("external-source", locked.source.as_bytes());
3897        hasher.field(
3898            "external-checksum",
3899            locked.checksum.as_deref().unwrap_or_default().as_bytes(),
3900        );
3901    }
3902    Some(hasher.finish())
3903}
3904
3905fn semantic_package_projection(
3906    package: &MetadataPackage,
3907    node: &Value,
3908    identities: &HashMap<&str, InputDigest>,
3909) -> Result<InputDigest, WasmBuildError> {
3910    // These are the effective package values Cargo can expose to compilation
3911    // through CARGO_PKG_* variables. Local manifests and external checksums
3912    // cover the remaining package definition.
3913    let mut hasher = InputHasher::new("wasm-semantic-package-projection-v1");
3914    for (field, value) in &package.semantic_fields {
3915        hasher.field("package-field-name", field.as_bytes());
3916        match value {
3917            Some(value) => hasher.field("package-field-value", value.as_bytes()),
3918            None => hasher.field("package-field-missing", b""),
3919        }
3920    }
3921
3922    let mut features = node
3923        .get("features")
3924        .and_then(Value::as_array)
3925        .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no features array"))?
3926        .iter()
3927        .map(|feature| {
3928            feature.as_str().map(str::to_owned).ok_or_else(|| {
3929                invalid_metadata("Cargo metadata dependency feature is not a string")
3930            })
3931        })
3932        .collect::<Result<Vec<_>, _>>()?;
3933    features.sort();
3934    for feature in features {
3935        hasher.field("enabled-feature", feature.as_bytes());
3936    }
3937
3938    let mut dependencies = node
3939        .get("deps")
3940        .and_then(Value::as_array)
3941        .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
3942        .iter()
3943        .map(|dependency| {
3944            let name = required_string(dependency, "name")?;
3945            let package_id = required_string(dependency, "pkg")?;
3946            let identity = identities
3947                .get(package_id.as_str())
3948                .copied()
3949                .ok_or_else(|| {
3950                    invalid_metadata(&format!(
3951                        "dependency `{package_id}` is outside the selected package closure"
3952                    ))
3953                })?;
3954            let kinds = dependency
3955                .get("dep_kinds")
3956                .ok_or_else(|| invalid_metadata("Cargo metadata dependency has no kind array"))?
3957                .to_string();
3958            Ok::<_, WasmBuildError>((name, identity, kinds))
3959        })
3960        .collect::<Result<Vec<_>, _>>()?;
3961    dependencies.sort();
3962    for (name, identity, kinds) in dependencies {
3963        hasher.field("dependency-name", name.as_bytes());
3964        hasher.field("dependency-identity", identity.as_bytes());
3965        hasher.field("dependency-kinds", kinds.as_bytes());
3966    }
3967    Ok(hasher.finish())
3968}
3969
3970fn hash_toml_setting(hasher: &mut InputHasher, label: &str, value: Option<&TomlValue>) {
3971    hasher.field("workspace-setting-name", label.as_bytes());
3972    match value {
3973        Some(value) => hasher.field("workspace-setting-value", value.to_string().as_bytes()),
3974        None => hasher.field("workspace-setting-missing", b""),
3975    }
3976}
3977
3978fn is_broad_workspace_input(label: &Path) -> bool {
3979    label == Path::new("workspace/Cargo.toml") || label == Path::new("workspace/Cargo.lock")
3980}
3981
3982fn digest_resolved_local_inputs(
3983    inputs: &ResolvedLocalInputs,
3984    exclusions: &[PathBuf],
3985    cache: &mut LabeledPathDigestCache,
3986    error_path: &Path,
3987    validation_operation: &'static str,
3988    semantic_operation: &'static str,
3989) -> Result<(InputDigest, InputDigest), WasmBuildError> {
3990    let validation_digest = digest_labeled_paths_composable(
3991        "wasm-source-inputs-v1",
3992        &inputs.validation_inputs,
3993        exclusions,
3994        cache,
3995    )
3996    .map_err(|source| WasmBuildError::Io {
3997        operation: validation_operation,
3998        path: error_path.to_owned(),
3999        source,
4000    })?;
4001    let input_digest = semantic_input_digest(inputs, validation_digest, exclusions, cache)
4002        .map_err(|source| WasmBuildError::Io {
4003            operation: semantic_operation,
4004            path: error_path.to_owned(),
4005            source,
4006        })?;
4007    Ok((input_digest, validation_digest))
4008}
4009
4010fn semantic_input_digest(
4011    inputs: &ResolvedLocalInputs,
4012    validation_digest: InputDigest,
4013    exclusions: &[PathBuf],
4014    cache: &mut LabeledPathDigestCache,
4015) -> io::Result<InputDigest> {
4016    let LocalInputFingerprint::Projected {
4017        inputs: fingerprint_inputs,
4018        workspace,
4019    } = &inputs.fingerprint
4020    else {
4021        return Ok(validation_digest);
4022    };
4023    let path_digest = digest_labeled_paths_composable(
4024        "wasm-source-inputs-v1",
4025        fingerprint_inputs,
4026        exclusions,
4027        cache,
4028    )?;
4029    let mut hasher = InputHasher::new("wasm-semantic-source-inputs-v1");
4030    hasher.field("path-input-digest", path_digest.as_bytes());
4031    hasher.field("workspace-projection", workspace.as_bytes());
4032    Ok(hasher.finish())
4033}
4034
4035fn workspace_configuration_inputs(
4036    spec: &WasmBuildSpec,
4037    workspace_root: &Path,
4038) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
4039    let mut inputs = Vec::new();
4040    add_if_present(
4041        &mut inputs,
4042        "workspace/Cargo.toml",
4043        workspace_root.join("Cargo.toml"),
4044    );
4045    add_if_present(
4046        &mut inputs,
4047        "workspace/Cargo.lock",
4048        workspace_root.join("Cargo.lock"),
4049    );
4050    add_if_present(
4051        &mut inputs,
4052        "workspace/rust-toolchain.toml",
4053        workspace_root.join("rust-toolchain.toml"),
4054    );
4055    add_if_present(
4056        &mut inputs,
4057        "workspace/rust-toolchain",
4058        workspace_root.join("rust-toolchain"),
4059    );
4060    append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
4061    Ok(inputs)
4062}
4063
4064fn append_cargo_configuration_inputs(
4065    inputs: &mut Vec<(PathBuf, PathBuf)>,
4066    spec: &WasmBuildSpec,
4067    workspace_root: &Path,
4068) -> Result<(), WasmBuildError> {
4069    let invocation_root =
4070        spec.workspace_root
4071            .canonicalize()
4072            .map_err(|source| WasmBuildError::Io {
4073                operation: "resolve Cargo invocation directory",
4074                path: spec.workspace_root.clone(),
4075                source,
4076            })?;
4077    let canonical_workspace =
4078        workspace_root
4079            .canonicalize()
4080            .map_err(|source| WasmBuildError::Io {
4081                operation: "resolve Cargo workspace directory",
4082                path: workspace_root.to_owned(),
4083                source,
4084            })?;
4085
4086    let mut roots = invocation_root
4087        .ancestors()
4088        .filter_map(|directory| effective_cargo_config(&directory.join(".cargo")))
4089        .collect::<Vec<_>>();
4090    if let Some(cargo_home) = effective_cargo_home(spec, &invocation_root)
4091        && let Some(config) = effective_cargo_config(&cargo_home)
4092    {
4093        roots.push(config);
4094    }
4095
4096    let mut visited = BTreeSet::new();
4097    for config in roots {
4098        append_cargo_configuration_tree(
4099            inputs,
4100            &config,
4101            &canonical_workspace,
4102            &mut visited,
4103            false,
4104        )?;
4105    }
4106    Ok(())
4107}
4108
4109fn effective_cargo_config(directory: &Path) -> Option<PathBuf> {
4110    let extensionless = directory.join("config");
4111    if extensionless.exists() {
4112        return Some(extensionless);
4113    }
4114    let toml = directory.join("config.toml");
4115    toml.exists().then_some(toml)
4116}
4117
4118fn effective_cargo_home(spec: &WasmBuildSpec, invocation_root: &Path) -> Option<PathBuf> {
4119    if let Some(cargo_home) = command_environment_value(spec, "CARGO_HOME") {
4120        let cargo_home = PathBuf::from(cargo_home);
4121        return Some(if cargo_home.is_absolute() {
4122            cargo_home
4123        } else {
4124            invocation_root.join(cargo_home)
4125        });
4126    }
4127
4128    default_home_directory(spec).map(|home| {
4129        let home = if home.is_absolute() {
4130            home
4131        } else {
4132            invocation_root.join(home)
4133        };
4134        home.join(".cargo")
4135    })
4136}
4137
4138#[cfg(windows)]
4139fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4140    command_environment_value(spec, "USERPROFILE")
4141        .or_else(|| command_environment_value(spec, "HOME"))
4142        .map(PathBuf::from)
4143}
4144
4145#[cfg(not(windows))]
4146fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4147    command_environment_value(spec, "HOME").map(PathBuf::from)
4148}
4149
4150fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
4151    spec.extra_env
4152        .get(OsStr::new(name))
4153        .cloned()
4154        .or_else(|| std::env::var_os(name))
4155}
4156
4157fn append_cargo_configuration_tree(
4158    inputs: &mut Vec<(PathBuf, PathBuf)>,
4159    config: &Path,
4160    workspace_root: &Path,
4161    visited: &mut BTreeSet<PathBuf>,
4162    optional: bool,
4163) -> Result<(), WasmBuildError> {
4164    let canonical = match config.canonicalize() {
4165        Ok(canonical) => canonical,
4166        Err(error) if optional && error.kind() == io::ErrorKind::NotFound => return Ok(()),
4167        Err(source) => {
4168            return Err(WasmBuildError::Io {
4169                operation: "resolve Cargo configuration",
4170                path: config.to_owned(),
4171                source,
4172            });
4173        }
4174    };
4175    if !visited.insert(canonical.clone()) {
4176        return Ok(());
4177    }
4178
4179    let contents = fs::read_to_string(&canonical).map_err(|source| WasmBuildError::Io {
4180        operation: "read Cargo configuration",
4181        path: canonical.clone(),
4182        source,
4183    })?;
4184    let configuration = toml::from_str::<TomlValue>(&contents).map_err(|error| {
4185        WasmBuildError::InvalidCargoConfiguration {
4186            path: canonical.clone(),
4187            message: error.to_string(),
4188        }
4189    })?;
4190    inputs.push((
4191        cargo_configuration_label(&canonical, workspace_root),
4192        canonical.clone(),
4193    ));
4194
4195    let Some(include) = configuration.get("include") else {
4196        return Ok(());
4197    };
4198    let parent = canonical
4199        .parent()
4200        .ok_or_else(|| WasmBuildError::InvalidCargoConfiguration {
4201            path: canonical.clone(),
4202            message: "configuration path has no parent directory".to_owned(),
4203        })?;
4204    for (included, optional) in cargo_configuration_includes(include, &canonical)? {
4205        let included = if included.is_absolute() {
4206            included
4207        } else {
4208            parent.join(included)
4209        };
4210        append_cargo_configuration_tree(inputs, &included, workspace_root, visited, optional)?;
4211    }
4212    Ok(())
4213}
4214
4215fn cargo_configuration_includes(
4216    include: &TomlValue,
4217    config: &Path,
4218) -> Result<Vec<(PathBuf, bool)>, WasmBuildError> {
4219    let values = match include {
4220        TomlValue::Array(values) => values.as_slice(),
4221        value => std::slice::from_ref(value),
4222    };
4223    values
4224        .iter()
4225        .map(|value| match value {
4226            TomlValue::String(path) => Ok((PathBuf::from(path), false)),
4227            TomlValue::Table(table) => {
4228                let path = table
4229                    .get("path")
4230                    .and_then(TomlValue::as_str)
4231                    .ok_or_else(|| {
4232                        invalid_cargo_configuration(
4233                            config,
4234                            "Cargo configuration include table requires a string `path`",
4235                        )
4236                    })?;
4237                let optional = table
4238                    .get("optional")
4239                    .map(|value| {
4240                        value.as_bool().ok_or_else(|| {
4241                            invalid_cargo_configuration(
4242                                config,
4243                                "Cargo configuration include `optional` must be a boolean",
4244                            )
4245                        })
4246                    })
4247                    .transpose()?
4248                    .unwrap_or(false);
4249                Ok((PathBuf::from(path), optional))
4250            }
4251            _ => Err(invalid_cargo_configuration(
4252                config,
4253                "Cargo configuration `include` must contain paths or include tables",
4254            )),
4255        })
4256        .collect()
4257}
4258
4259fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
4260    if let Ok(relative) = config.strip_prefix(workspace_root) {
4261        return PathBuf::from("cargo-config/workspace").join(relative);
4262    }
4263    let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
4264    PathBuf::from("cargo-config/external").join(location.to_hex())
4265}
4266
4267fn invalid_cargo_configuration(path: &Path, message: &str) -> WasmBuildError {
4268    WasmBuildError::InvalidCargoConfiguration {
4269        path: path.to_owned(),
4270        message: message.to_owned(),
4271    }
4272}
4273
4274fn append_package_inputs(
4275    inputs: &mut Vec<(PathBuf, PathBuf)>,
4276    packages: &HashMap<String, MetadataPackage>,
4277    closure: BTreeSet<String>,
4278    workspace_root: &Path,
4279) -> Result<(), WasmBuildError> {
4280    for id in closure {
4281        let Some(package) = packages.get(&id) else {
4282            return Err(invalid_metadata(&format!(
4283                "resolved package `{id}` is missing"
4284            )));
4285        };
4286        if !package.is_local {
4287            continue;
4288        }
4289        let root = package.manifest_path.parent().ok_or_else(|| {
4290            invalid_metadata(&format!(
4291                "package `{}` manifest has no parent",
4292                package.name
4293            ))
4294        })?;
4295        let relative_manifest = package
4296            .manifest_path
4297            .strip_prefix(workspace_root)
4298            .unwrap_or(&package.manifest_path);
4299        let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
4300            .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
4301        inputs.push((label, root.to_owned()));
4302    }
4303    Ok(())
4304}
4305
4306fn append_additional_inputs(
4307    inputs: &mut Vec<(PathBuf, PathBuf)>,
4308    spec: &WasmBuildSpec,
4309    workspace_root: &Path,
4310) {
4311    for additional in &spec.additional_inputs {
4312        let path = if additional.is_absolute() {
4313            additional.clone()
4314        } else {
4315            workspace_root.join(additional)
4316        };
4317        inputs.push((PathBuf::from("additional").join(additional), path));
4318    }
4319}
4320
4321fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
4322    let mut exclusions = vec![
4323        spec.target_dir.clone(),
4324        spec.workspace_root.join("target"),
4325        spec.workspace_root.join(".git"),
4326    ];
4327    if let Some(shared_target) = shared_incremental_target(spec) {
4328        exclusions.push(shared_target);
4329    }
4330    for (_, path) in inputs {
4331        if path.is_dir() {
4332            exclusions.push(path.join("target"));
4333            exclusions.push(path.join(".git"));
4334        }
4335    }
4336    exclusions
4337}
4338
4339fn validate_shared_incremental_target_boundary(
4340    spec: &WasmBuildSpec,
4341    inputs: &[(PathBuf, PathBuf)],
4342) -> Result<(), WasmBuildError> {
4343    let Some(shared_target) = shared_incremental_target(spec) else {
4344        return Ok(());
4345    };
4346    let shared_target =
4347        canonicalize_allow_missing(&shared_target).map_err(|source| WasmBuildError::Io {
4348            operation: "resolve shared incremental Cargo target boundary",
4349            path: shared_target.clone(),
4350            source,
4351        })?;
4352    let resolved_inputs = inputs
4353        .iter()
4354        .map(|(_, input)| {
4355            let canonical = input.canonicalize().map_err(|source| WasmBuildError::Io {
4356                operation: "resolve Cargo input boundary",
4357                path: input.clone(),
4358                source,
4359            })?;
4360            let metadata = fs::metadata(&canonical).map_err(|source| WasmBuildError::Io {
4361                operation: "inspect Cargo input boundary",
4362                path: canonical.clone(),
4363                source,
4364            })?;
4365            Ok((canonical, metadata.is_dir()))
4366        })
4367        .collect::<Result<Vec<_>, WasmBuildError>>()?;
4368    let safe_generated_roots = std::iter::once(spec.target_dir.clone())
4369        .chain(std::iter::once(spec.workspace_root.join("target")))
4370        .chain(
4371            inputs
4372                .iter()
4373                .filter(|(_, path)| path.is_dir())
4374                .map(|(_, path)| path.join("target")),
4375        )
4376        .filter_map(|path| canonicalize_allow_missing(&path).ok())
4377        .filter(|root| {
4378            !resolved_inputs
4379                .iter()
4380                .any(|(input, _is_directory)| input.starts_with(root))
4381        })
4382        .collect::<Vec<_>>();
4383    if safe_generated_roots
4384        .iter()
4385        .any(|root| shared_target.starts_with(root))
4386    {
4387        return Ok(());
4388    }
4389
4390    for (input, is_directory) in resolved_inputs {
4391        if shared_target == input
4392            || (is_directory && shared_target.starts_with(&input))
4393            || input.starts_with(&shared_target)
4394        {
4395            return Err(WasmBuildError::InvalidSpec {
4396                message: format!(
4397                    "shared incremental target {} must not overlap exact Cargo inputs unless it is inside a generated target directory",
4398                    shared_target.display()
4399                ),
4400            });
4401        }
4402    }
4403    Ok(())
4404}
4405
4406fn canonicalize_allow_missing(path: &Path) -> io::Result<PathBuf> {
4407    let absolute = if path.is_absolute() {
4408        path.to_owned()
4409    } else {
4410        std::env::current_dir()?.join(path)
4411    };
4412    let mut unresolved = Vec::<OsString>::new();
4413    let mut existing = absolute.as_path();
4414    loop {
4415        match existing.canonicalize() {
4416            Ok(mut canonical) => {
4417                for component in unresolved.into_iter().rev() {
4418                    canonical.push(component);
4419                }
4420                return Ok(canonical);
4421            }
4422            Err(error) if error.kind() == io::ErrorKind::NotFound => {
4423                let Some(name) = existing.file_name() else {
4424                    return Err(error);
4425                };
4426                unresolved.push(name.to_owned());
4427                existing = existing.parent().ok_or(error)?;
4428            }
4429            Err(error) => return Err(error),
4430        }
4431    }
4432}
4433
4434fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
4435    let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
4436        return None;
4437    };
4438    Some(if target_dir.is_absolute() {
4439        target_dir.clone()
4440    } else {
4441        spec.workspace_root.join(target_dir)
4442    })
4443}
4444
4445fn shared_incremental_target_exists(
4446    spec: &WasmBuildSpec,
4447    operation: &'static str,
4448) -> Result<bool, WasmBuildError> {
4449    let target_dir =
4450        shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
4451            message: "shared incremental target is not configured".to_owned(),
4452        })?;
4453    match fs::symlink_metadata(&target_dir) {
4454        Ok(metadata) if metadata.is_dir() => Ok(true),
4455        Ok(_) => Err(WasmBuildError::InvalidSpec {
4456            message: format!(
4457                "shared incremental Cargo target {} must be a directory",
4458                target_dir.display()
4459            ),
4460        }),
4461        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
4462        Err(source) => Err(WasmBuildError::Io {
4463            operation,
4464            path: target_dir,
4465            source,
4466        }),
4467    }
4468}
4469
4470fn effective_environment(spec: &WasmBuildSpec) -> BTreeMap<OsString, Option<OsString>> {
4471    let mut names = spec.inherited_env.clone();
4472    names.extend(AUTOMATIC_ENVIRONMENT.iter().map(OsString::from));
4473    let mut environment = names
4474        .into_iter()
4475        .map(|name| {
4476            let value = std::env::var_os(&name);
4477            (name, value)
4478        })
4479        .collect::<BTreeMap<_, _>>();
4480    for (key, value) in &spec.extra_env {
4481        environment.insert(key.clone(), Some(value.clone()));
4482    }
4483    environment
4484}
4485
4486fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
4487    for (key, value) in &spec.extra_env {
4488        command.env(key, value);
4489    }
4490}
4491
4492fn run_cargo_build(
4493    spec: &WasmBuildSpec,
4494    build_target_dir: &Path,
4495    progress: &mut ProgressReporter<'_>,
4496) -> Result<(), WasmBuildError> {
4497    let mut command = Command::new(&spec.cargo_program);
4498    command
4499        .current_dir(&spec.workspace_root)
4500        .env("CARGO_TARGET_DIR", build_target_dir)
4501        .args(["build", "--target", &spec.target])
4502        .args(&spec.cargo_profile_args);
4503    apply_command_environment(&mut command, spec);
4504    for package in &spec.packages {
4505        command.args(["-p", package]);
4506    }
4507
4508    if !progress.is_observed() {
4509        let output = command
4510            .output()
4511            .map_err(|source| WasmBuildError::CommandSpawn {
4512                phase: WasmBuildPhase::CargoBuild,
4513                program: spec.cargo_program.clone(),
4514                source,
4515            })?;
4516        return ensure_command_success(WasmBuildPhase::CargoBuild, output).map(|_| ());
4517    }
4518
4519    run_observed_cargo_build(spec, build_target_dir, command, progress)
4520}
4521
4522fn run_observed_cargo_build(
4523    spec: &WasmBuildSpec,
4524    build_target_dir: &Path,
4525    mut command: Command,
4526    progress: &mut ProgressReporter<'_>,
4527) -> Result<(), WasmBuildError> {
4528    command.stdout(Stdio::piped()).stderr(Stdio::piped());
4529    let started = Instant::now();
4530    let child = command
4531        .spawn()
4532        .map_err(|source| WasmBuildError::CommandSpawn {
4533            phase: WasmBuildPhase::CargoBuild,
4534            program: spec.cargo_program.clone(),
4535            source,
4536        })?;
4537    let mut child = ObservedChild::new(child);
4538    progress.emit(WasmBuildProgressEvent::CargoStarted {
4539        target_dir: build_target_dir.to_owned(),
4540    });
4541
4542    let stdout = child
4543        .child_mut()
4544        .stdout
4545        .take()
4546        .expect("Cargo stdout must be piped");
4547    let stderr = child
4548        .child_mut()
4549        .stderr
4550        .take()
4551        .expect("Cargo stderr must be piped");
4552    let (sender, chunks) = mpsc::channel();
4553    let stdout_sender = sender.clone();
4554    let stdout_reader = thread::spawn(move || {
4555        read_process_output(stdout, WasmBuildOutputStream::Stdout, stdout_sender)
4556    });
4557    let stderr_reader =
4558        thread::spawn(move || read_process_output(stderr, WasmBuildOutputStream::Stderr, sender));
4559
4560    let captured = capture_observed_cargo_output(chunks, progress, started);
4561
4562    let status = child.wait().map_err(|source| WasmBuildError::Io {
4563        operation: "wait for observed cargo build",
4564        path: PathBuf::from(&spec.cargo_program),
4565        source,
4566    })?;
4567    join_output_reader(
4568        stdout_reader,
4569        "read observed cargo stdout",
4570        &spec.cargo_program,
4571    )?;
4572    join_output_reader(
4573        stderr_reader,
4574        "read observed cargo stderr",
4575        &spec.cargo_program,
4576    )?;
4577    let elapsed = started.elapsed();
4578    progress.emit(WasmBuildProgressEvent::CargoFinished {
4579        success: status.success(),
4580        code: status.code(),
4581        elapsed,
4582    });
4583
4584    ensure_command_success(
4585        WasmBuildPhase::CargoBuild,
4586        Output {
4587            status,
4588            stdout: captured.stdout,
4589            stderr: captured.stderr,
4590        },
4591    )
4592    .map(|_| ())
4593}
4594
4595struct CapturedProcessOutput {
4596    stdout: Vec<u8>,
4597    stderr: Vec<u8>,
4598}
4599
4600fn capture_observed_cargo_output(
4601    chunks: mpsc::Receiver<ProcessOutputChunk>,
4602    progress: &mut ProgressReporter<'_>,
4603    started: Instant,
4604) -> CapturedProcessOutput {
4605    let mut stdout = Vec::new();
4606    let mut stderr = Vec::new();
4607    loop {
4608        let message = match progress.heartbeat_due_in() {
4609            Some(wait) => match chunks.recv_timeout(wait) {
4610                Ok(chunk) => Some(chunk),
4611                Err(RecvTimeoutError::Timeout) => {
4612                    progress.emit_heartbeat(WasmBuildProgressPhase::CargoBuild, started.elapsed());
4613                    None
4614                }
4615                Err(RecvTimeoutError::Disconnected) => break,
4616            },
4617            None => match chunks.recv() {
4618                Ok(chunk) => Some(chunk),
4619                Err(_) => break,
4620            },
4621        };
4622        let Some(chunk) = message else {
4623            continue;
4624        };
4625        match chunk.stream {
4626            WasmBuildOutputStream::Stdout => stdout.extend_from_slice(&chunk.bytes),
4627            WasmBuildOutputStream::Stderr => stderr.extend_from_slice(&chunk.bytes),
4628        }
4629        if progress.config.emit_cargo_output {
4630            progress.emit(WasmBuildProgressEvent::CargoOutput {
4631                stream: chunk.stream,
4632                bytes: chunk.bytes,
4633            });
4634        }
4635    }
4636    CapturedProcessOutput { stdout, stderr }
4637}
4638
4639#[derive(Debug)]
4640struct ProcessOutputChunk {
4641    stream: WasmBuildOutputStream,
4642    bytes: Vec<u8>,
4643}
4644
4645fn read_process_output<R: io::Read>(
4646    mut reader: R,
4647    stream: WasmBuildOutputStream,
4648    sender: mpsc::Sender<ProcessOutputChunk>,
4649) -> io::Result<()> {
4650    let mut buffer = [0_u8; 8 * 1024];
4651    loop {
4652        let count = reader.read(&mut buffer)?;
4653        if count == 0 {
4654            return Ok(());
4655        }
4656        if sender
4657            .send(ProcessOutputChunk {
4658                stream,
4659                bytes: buffer[..count].to_vec(),
4660            })
4661            .is_err()
4662        {
4663            return Ok(());
4664        }
4665    }
4666}
4667
4668fn join_output_reader(
4669    reader: thread::JoinHandle<io::Result<()>>,
4670    operation: &'static str,
4671    cargo_program: &OsStr,
4672) -> Result<(), WasmBuildError> {
4673    let result = reader.join().map_err(|_| WasmBuildError::Io {
4674        operation,
4675        path: PathBuf::from(cargo_program),
4676        source: io::Error::other("Cargo output reader panicked"),
4677    })?;
4678    result.map_err(|source| WasmBuildError::Io {
4679        operation,
4680        path: PathBuf::from(cargo_program),
4681        source,
4682    })
4683}
4684
4685struct ObservedChild(Option<Child>);
4686
4687impl ObservedChild {
4688    const fn new(child: Child) -> Self {
4689        Self(Some(child))
4690    }
4691
4692    const fn child_mut(&mut self) -> &mut Child {
4693        self.0.as_mut().expect("observed child must be present")
4694    }
4695
4696    fn wait(&mut self) -> io::Result<ExitStatus> {
4697        let status = self.child_mut().wait()?;
4698        self.0.take();
4699        Ok(status)
4700    }
4701}
4702
4703impl Drop for ObservedChild {
4704    fn drop(&mut self) {
4705        if let Some(mut child) = self.0.take() {
4706            let _ = child.kill();
4707            let _ = child.wait();
4708        }
4709    }
4710}
4711
4712fn ensure_command_success(phase: WasmBuildPhase, output: Output) -> Result<Output, WasmBuildError> {
4713    if output.status.success() {
4714        return Ok(output);
4715    }
4716    Err(WasmBuildError::CommandFailed {
4717        phase,
4718        status: output.status,
4719        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
4720        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
4721    })
4722}
4723
4724fn expected_artifacts(spec: &WasmBuildSpec, target_dir: &Path) -> Vec<PathBuf> {
4725    let mut packages = spec.packages.iter().map(String::as_str).collect::<Vec<_>>();
4726    packages.sort_unstable();
4727    packages.dedup();
4728    packages
4729        .into_iter()
4730        .map(|package| {
4731            if spec.target == DEFAULT_TARGET {
4732                wasm_path(target_dir, package, &spec.profile_target_dir)
4733            } else {
4734                target_dir
4735                    .join(&spec.target)
4736                    .join(&spec.profile_target_dir)
4737                    .join(format!("{package}.wasm"))
4738            }
4739        })
4740        .collect()
4741}
4742
4743fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
4744    spec.target_dir
4745        .join(".ic-testkit/wasm-targets")
4746        .join(fingerprint.to_hex())
4747}
4748
4749fn artifact_set_matches(artifacts: &[PathBuf], fingerprint: InputDigest) -> bool {
4750    artifacts.iter().all(|path| {
4751        fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
4752            && cache_stamp_matches(path, fingerprint)
4753    })
4754}
4755
4756fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
4757    artifacts
4758        .iter()
4759        .filter(|path| {
4760            fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
4761        })
4762        .cloned()
4763        .collect()
4764}
4765
4766fn cache_stamp_matches(artifact: &Path, fingerprint: InputDigest) -> bool {
4767    let stamp_path = artifact_stamp_path(artifact);
4768    let Ok(expected) = artifact_stamp_contents(artifact, fingerprint) else {
4769        return false;
4770    };
4771    fs::read_to_string(stamp_path).is_ok_and(|stamp| stamp == expected)
4772}
4773
4774fn artifact_stamp_path(artifact: &Path) -> PathBuf {
4775    let mut name = artifact
4776        .file_name()
4777        .map_or_else(|| OsString::from("artifact"), OsString::from);
4778    name.push(".ic-testkit-build");
4779    artifact.with_file_name(name)
4780}
4781
4782fn artifact_stamp_contents(artifact: &Path, fingerprint: InputDigest) -> io::Result<String> {
4783    let (_, artifact_digest) = digest_file("wasm-artifact-v1", artifact)?;
4784    Ok(format!(
4785        "{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\nartifact-sha256:{artifact_digest}\n"
4786    ))
4787}
4788
4789fn publish_artifact_stamps(
4790    artifacts: &[PathBuf],
4791    fingerprint: InputDigest,
4792) -> Result<(), WasmBuildError> {
4793    for artifact in artifacts {
4794        let stamp_path = artifact_stamp_path(artifact);
4795        let stamp = artifact_stamp_contents(artifact, fingerprint).map_err(|source| {
4796            WasmBuildError::Io {
4797                operation: "hash built Wasm artifact",
4798                path: artifact.clone(),
4799                source,
4800            }
4801        })?;
4802        write_atomic(&stamp_path, stamp.as_bytes()).map_err(|source| WasmBuildError::Io {
4803            operation: "publish Wasm build stamp",
4804            path: stamp_path,
4805            source,
4806        })?;
4807    }
4808    Ok(())
4809}
4810
4811fn materialize_artifacts(
4812    cached_artifacts: &[PathBuf],
4813    artifacts: &[PathBuf],
4814    fingerprint: InputDigest,
4815) -> Result<(), WasmBuildError> {
4816    for (cached, artifact) in cached_artifacts.iter().zip(artifacts) {
4817        copy_file_atomic(cached, artifact).map_err(|source| WasmBuildError::Io {
4818            operation: "publish Wasm artifact",
4819            path: artifact.clone(),
4820            source,
4821        })?;
4822    }
4823    publish_artifact_stamps(artifacts, fingerprint)
4824}
4825
4826fn copy_wasm_artifacts(
4827    source_artifacts: &[PathBuf],
4828    cached_artifacts: &[PathBuf],
4829) -> Result<(), WasmBuildError> {
4830    for (source, cached) in source_artifacts.iter().zip(cached_artifacts) {
4831        copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
4832            operation: "cache shared-incremental Wasm artifact",
4833            path: cached.clone(),
4834            source: source_error,
4835        })?;
4836    }
4837    Ok(())
4838}
4839
4840fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
4841    fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
4842        operation,
4843        path: path.to_owned(),
4844        source,
4845    })
4846}
4847
4848fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
4849    if path.exists() {
4850        inputs.push((PathBuf::from(label), path));
4851    }
4852}
4853
4854fn required_string(value: &Value, field: &str) -> Result<String, WasmBuildError> {
4855    value
4856        .get(field)
4857        .and_then(Value::as_str)
4858        .map(str::to_owned)
4859        .ok_or_else(|| invalid_metadata(&format!("Cargo metadata field `{field}` is missing")))
4860}
4861
4862fn optional_string(value: &Value, field: &str) -> Result<Option<String>, WasmBuildError> {
4863    match value.get(field) {
4864        None | Some(Value::Null) => Ok(None),
4865        Some(Value::String(value)) => Ok(Some(value.clone())),
4866        Some(_) => Err(invalid_metadata(&format!(
4867            "Cargo metadata field `{field}` is not a string or null"
4868        ))),
4869    }
4870}
4871
4872fn invalid_metadata(message: &str) -> WasmBuildError {
4873    WasmBuildError::InvalidMetadata {
4874        message: message.to_owned(),
4875    }
4876}
4877
4878impl WasmBuildError {
4879    fn indicates_input_change(&self) -> bool {
4880        match self {
4881            Self::InputsChangedDuringBuild { .. } => true,
4882            Self::FailedBuildCleanup { build_error, .. } => build_error.indicates_input_change(),
4883            _ => false,
4884        }
4885    }
4886}
4887
4888impl std::fmt::Display for WasmBuildPhase {
4889    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4890        formatter.write_str(match self {
4891            Self::CargoMetadata => "cargo metadata",
4892            Self::CargoIdentity => "Cargo identity",
4893            Self::RustcIdentity => "Rust compiler identity",
4894            Self::CargoBuild => "cargo build",
4895        })
4896    }
4897}
4898
4899impl std::fmt::Display for WasmBuildProgressPhase {
4900    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4901        formatter.write_str(match self {
4902            Self::ExactCacheLock => "exact cache lock",
4903            Self::CargoIdentity => "Cargo identity",
4904            Self::RustcIdentity => "Rust compiler identity",
4905            Self::CargoMetadata => "Cargo metadata",
4906            Self::InputDiscovery => "input discovery",
4907            Self::ContentHashing => "content hashing",
4908            Self::SharedTargetLock => "shared target lock",
4909            Self::SharedTargetMaintenance => "shared target maintenance",
4910            Self::CargoBuild => "Cargo build",
4911            Self::ArtifactPublication => "artifact publication",
4912            Self::ExactCacheMaintenance => "exact cache maintenance",
4913        })
4914    }
4915}
4916
4917impl std::fmt::Display for WasmBuildError {
4918    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4919        match self {
4920            Self::InvalidSpec { message } => {
4921                write!(formatter, "invalid Wasm build spec: {message}")
4922            }
4923            Self::Io {
4924                operation,
4925                path,
4926                source,
4927            } => write!(
4928                formatter,
4929                "failed to {operation} at {}: {source}",
4930                path.display()
4931            ),
4932            Self::CommandSpawn {
4933                phase,
4934                program,
4935                source,
4936            } => write!(
4937                formatter,
4938                "failed to launch {phase} using `{}`: {source}",
4939                program.to_string_lossy(),
4940            ),
4941            Self::CommandFailed {
4942                phase,
4943                status,
4944                stdout,
4945                stderr,
4946            } => write!(
4947                formatter,
4948                "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
4949            ),
4950            Self::InvalidMetadata { message } => {
4951                write!(formatter, "invalid Cargo metadata: {message}")
4952            }
4953            Self::InvalidCargoConfiguration { path, message } => write!(
4954                formatter,
4955                "invalid Cargo configuration at {}: {message}",
4956                path.display(),
4957            ),
4958            Self::MissingArtifacts { paths } => write!(
4959                formatter,
4960                "cargo build succeeded without producing: {}",
4961                paths
4962                    .iter()
4963                    .map(|path| path.display().to_string())
4964                    .collect::<Vec<_>>()
4965                    .join(", "),
4966            ),
4967            Self::InputsChangedDuringBuild { before, after } => write!(
4968                formatter,
4969                "Wasm build inputs changed while Cargo was running: {before} -> {after}",
4970            ),
4971            Self::PreparedInputSnapshotInvalidated => formatter.write_str(
4972                "the prepared Wasm input snapshot was invalidated before artifact publication",
4973            ),
4974            Self::FailedBuildCleanup {
4975                build_error,
4976                path,
4977                source,
4978            } => write!(
4979                formatter,
4980                "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
4981                path.display(),
4982            ),
4983        }
4984    }
4985}
4986
4987impl std::error::Error for WasmBuildError {
4988    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
4989        match self {
4990            Self::Io { source, .. }
4991            | Self::CommandSpawn { source, .. }
4992            | Self::FailedBuildCleanup { source, .. } => Some(source),
4993            _ => None,
4994        }
4995    }
4996}
4997
4998#[cfg(test)]
4999mod tests;