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