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