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