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