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