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::mpsc::{self, RecvTimeoutError},
10 thread,
11 time::{Duration, Instant, SystemTime},
12};
13use toml::Value as TomlValue;
14
15use crate::timing::saturating_add_optional_duration;
16
17use super::{
18 cache_fs::{
19 ArtifactCacheMaintenance, ArtifactCachePrunePolicy, ArtifactCachePruneReport, CacheFsError,
20 cache_entry_last_used, cache_maintenance_due, directory_logical_size,
21 ensure_cache_directory_tag as ensure_cache_tag, is_sha256_directory, lock_cache_file,
22 lock_cache_file_with_wait_observer, perform_scheduled_cache_maintenance,
23 prune_direct_child_directories, record_cache_entry_use as record_entry_use,
24 record_cache_maintenance, remove_path_if_present,
25 },
26 digest::{
27 InputDigest, InputHasher, LabeledPathDigestCache, copy_file_atomic, digest_bytes,
28 digest_file, digest_labeled_paths_composable, os_bytes, write_atomic,
29 },
30 wasm::wasm_path,
31};
32
33const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
34const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
35const AUTOMATIC_ENVIRONMENT: &[&str] = &[
36 "CARGO_BUILD_RUSTC",
37 "CARGO_ENCODED_RUSTFLAGS",
38 "RUSTC",
39 "RUSTC_WRAPPER",
40 "RUSTC_WORKSPACE_WRAPPER",
41 "RUSTFLAGS",
42 "RUSTUP_TOOLCHAIN",
43];
44
45#[derive(Clone, Debug, Eq, PartialEq)]
53pub struct WasmBuildSpec {
54 workspace_root: PathBuf,
55 target_dir: PathBuf,
56 packages: Vec<String>,
57 profile_target_dir: String,
58 cargo_profile_args: Vec<OsString>,
59 extra_env: BTreeMap<OsString, OsString>,
60 inherited_env: BTreeSet<OsString>,
61 additional_inputs: Vec<PathBuf>,
62 target: String,
63 cargo_program: OsString,
64 rustc_program: OsString,
65 cache_mode: WasmBuildCacheMode,
66 prune_policy: Option<ArtifactCachePrunePolicy>,
67 prune_interval: Option<Duration>,
68 shared_incremental_maintenance_config: Option<SharedIncrementalTargetMaintenanceConfig>,
69}
70
71#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
73pub enum SharedIncrementalTargetMaintenanceFailureMode {
74 #[default]
76 Strict,
77 BestEffort,
79}
80
81#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub struct SharedIncrementalTargetMaintenanceConfig {
84 policy: SharedIncrementalTargetPrunePolicy,
85 minimum_interval: Duration,
86 failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
87}
88
89#[non_exhaustive]
91#[derive(Clone, Debug, Eq, PartialEq)]
92pub enum WasmBuildCacheMode {
93 Isolated,
95 SharedIncremental {
97 target_dir: PathBuf,
99 },
100}
101
102#[derive(Clone, Debug, Eq, PartialEq)]
104pub enum WasmBuildOutcome {
105 Built(WasmBuildRecord),
107 Reused(WasmBuildRecord),
109}
110
111#[derive(Clone, Debug, Eq, PartialEq)]
113pub struct WasmBuildRecord {
114 fingerprint: InputDigest,
115 input_digest: InputDigest,
116 exact_cache_path: PathBuf,
117 artifacts: Vec<PathBuf>,
118 timings: WasmBuildTimings,
119 maintenance: Option<ArtifactCacheMaintenance>,
120 shared_incremental_maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
121}
122
123#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
125pub struct WasmBuildTimings {
126 lock_wait: Duration,
127 shared_incremental_lock_wait: Option<Duration>,
128 input_resolution: WasmInputResolutionTimings,
129 cargo_build: Option<Duration>,
130 cache_maintenance: Option<Duration>,
131 total: Duration,
132}
133
134#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
136pub struct WasmInputResolutionTimings {
137 tool_identity: Duration,
138 cargo_metadata: Duration,
139 input_discovery: Duration,
140 content_hashing: Duration,
141 total: Duration,
142}
143
144#[derive(Clone, Debug, Eq, PartialEq)]
146pub struct CargoBuildInput {
147 label: PathBuf,
148 path: PathBuf,
149}
150
151#[derive(Clone, Debug, Eq, PartialEq)]
156pub struct ResolvedCargoBuildInputs {
157 fingerprint: InputDigest,
158 input_digest: InputDigest,
159 validation_digest: InputDigest,
160 inputs: Vec<CargoBuildInput>,
161 exclusions: Vec<PathBuf>,
162 timings: WasmInputResolutionTimings,
163}
164
165pub(super) struct WasmBuildBatchInputResolver<'a> {
166 specs: &'a [WasmBuildSpec],
167 groups: Vec<BatchResolutionGroup>,
168 group_by_index: Vec<usize>,
169 resolved: Vec<Option<Result<ResolvedCargoBuildInputs, WasmBuildError>>>,
170 metrics: WasmBuildBatchInputMetrics,
171}
172
173struct BatchResolutionGroup {
174 indexes: Vec<usize>,
175}
176
177struct ResolvedLocalInputs {
178 validation_inputs: Vec<(PathBuf, PathBuf)>,
179 fingerprint: LocalInputFingerprint,
180}
181
182enum LocalInputFingerprint {
183 Conservative,
184 Projected {
185 inputs: Vec<(PathBuf, PathBuf)>,
186 workspace: InputDigest,
187 },
188}
189
190#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
191pub(super) struct WasmBuildBatchInputMetrics {
192 pub(super) runs: usize,
193 pub(super) reuses: usize,
194}
195
196#[derive(Eq, PartialEq)]
197struct BatchResolutionKey {
198 workspace_root: PathBuf,
199 cargo_program: OsString,
200 rustc_program: OsString,
201 metadata_arguments: Vec<OsString>,
202 environment: BTreeMap<OsString, Option<OsString>>,
203}
204
205#[derive(Clone, Debug, Eq, PartialEq)]
207pub struct SharedIncrementalTargetInspection {
208 target_dir: PathBuf,
209 logical_size_bytes: u64,
210 last_used: SystemTime,
211 lock_wait: Duration,
212}
213
214#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
222pub struct SharedIncrementalTargetPrunePolicy {
223 max_age: Option<Duration>,
224 max_size_bytes: Option<u64>,
225}
226
227#[derive(Clone, Debug, Eq, PartialEq)]
229pub struct SharedIncrementalTargetMaintenance {
230 target_dir: PathBuf,
231 logical_size_bytes_before: u64,
232 logical_size_bytes_after: u64,
233 last_used_before: SystemTime,
234 cleared: bool,
235 lock_wait: Duration,
236 maintenance: Duration,
237}
238
239#[non_exhaustive]
241#[derive(Clone, Debug, Eq, PartialEq)]
242pub enum SharedIncrementalTargetMaintenanceOutcome {
243 Missing {
245 target_dir: PathBuf,
247 },
248 Skipped {
250 target_dir: PathBuf,
252 lock_wait: Duration,
254 schedule_check: Duration,
256 },
257 Performed {
259 maintenance: SharedIncrementalTargetMaintenance,
261 schedule_check: Duration,
263 },
264 Failed {
266 target_dir: PathBuf,
268 lock_wait: Duration,
270 message: String,
272 },
273}
274
275#[derive(Clone, Copy, Debug, Eq, PartialEq)]
277pub struct WasmBuildProgressConfig {
278 heartbeat_interval: Option<Duration>,
279 emit_cargo_output: bool,
280}
281
282#[derive(Clone, Copy, Debug, Eq, PartialEq)]
284pub enum WasmBuildOutputStream {
285 Stdout,
287 Stderr,
289}
290
291#[derive(Clone, Copy, Debug, Eq, PartialEq)]
293pub enum WasmBuildProgressOutcome {
294 Built,
296 Reused,
298}
299
300#[non_exhaustive]
302#[derive(Clone, Copy, Debug, Eq, PartialEq)]
303pub enum WasmBuildProgressPhase {
304 ExactCacheLock,
306 CargoIdentity,
308 RustcIdentity,
310 CargoMetadata,
312 InputDiscovery,
314 ContentHashing,
316 SharedTargetLock,
318 SharedTargetMaintenance,
320 CargoBuild,
322 ArtifactPublication,
324 ExactCacheMaintenance,
326}
327
328#[non_exhaustive]
330#[derive(Clone, Debug, Eq, PartialEq)]
331pub enum WasmBuildProgressEvent {
332 Started,
334 InputsResolved {
336 fingerprint: InputDigest,
338 input_digest: InputDigest,
340 elapsed: Duration,
342 },
343 CacheMiss {
345 fingerprint: InputDigest,
347 },
348 CacheHit {
350 fingerprint: InputDigest,
352 },
353 SharedTargetLockStarted {
355 target_dir: PathBuf,
357 },
358 SharedTargetLockAcquired {
360 target_dir: PathBuf,
362 wait: Duration,
364 },
365 SharedTargetMaintenanceStarted {
367 target_dir: PathBuf,
369 },
370 SharedTargetMaintenanceFinished {
372 outcome: SharedIncrementalTargetMaintenanceOutcome,
374 },
375 CargoStarted {
377 target_dir: PathBuf,
379 },
380 CargoOutput {
382 stream: WasmBuildOutputStream,
384 bytes: Vec<u8>,
386 },
387 Heartbeat {
389 phase: WasmBuildProgressPhase,
391 elapsed: Duration,
393 },
394 CargoFinished {
396 success: bool,
398 code: Option<i32>,
400 elapsed: Duration,
402 },
403 Finished {
405 outcome: WasmBuildProgressOutcome,
407 fingerprint: InputDigest,
409 elapsed: Duration,
411 },
412}
413
414impl Default for WasmBuildProgressConfig {
415 fn default() -> Self {
416 Self {
417 heartbeat_interval: Some(Duration::from_secs(10)),
418 emit_cargo_output: true,
419 }
420 }
421}
422
423impl WasmBuildProgressConfig {
424 #[must_use]
426 pub fn new() -> Self {
427 Self::default()
428 }
429
430 #[must_use]
434 pub const fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
435 self.heartbeat_interval = Some(interval);
436 self
437 }
438
439 #[must_use]
441 pub const fn without_heartbeats(mut self) -> Self {
442 self.heartbeat_interval = None;
443 self
444 }
445
446 #[must_use]
450 pub const fn with_cargo_output(mut self, emit: bool) -> Self {
451 self.emit_cargo_output = emit;
452 self
453 }
454
455 #[must_use]
457 pub const fn heartbeat_interval(self) -> Option<Duration> {
458 self.heartbeat_interval
459 }
460
461 #[must_use]
463 pub const fn emits_cargo_output(self) -> bool {
464 self.emit_cargo_output
465 }
466}
467
468struct ProgressReporter<'a> {
469 config: WasmBuildProgressConfig,
470 observer: Option<&'a mut dyn FnMut(WasmBuildProgressEvent)>,
471 last_event: Instant,
472}
473
474impl ProgressReporter<'_> {
475 fn silent() -> Self {
476 Self {
477 config: WasmBuildProgressConfig {
478 heartbeat_interval: None,
479 emit_cargo_output: false,
480 },
481 observer: None,
482 last_event: Instant::now(),
483 }
484 }
485
486 fn observed(
487 config: WasmBuildProgressConfig,
488 observer: &'_ mut dyn FnMut(WasmBuildProgressEvent),
489 ) -> ProgressReporter<'_> {
490 ProgressReporter {
491 config,
492 observer: Some(observer),
493 last_event: Instant::now(),
494 }
495 }
496
497 fn emit(&mut self, event: WasmBuildProgressEvent) {
498 if let Some(observer) = &mut self.observer {
499 observer(event);
500 self.last_event = Instant::now();
501 }
502 }
503
504 const fn is_observed(&self) -> bool {
505 self.observer.is_some()
506 }
507
508 fn heartbeat_due_in(&self) -> Option<Duration> {
509 self.config
510 .heartbeat_interval
511 .map(|interval| interval.saturating_sub(self.last_event.elapsed()))
512 }
513
514 fn emit_heartbeat(&mut self, phase: WasmBuildProgressPhase, elapsed: Duration) {
515 self.emit(WasmBuildProgressEvent::Heartbeat { phase, elapsed });
516 }
517
518 fn emit_heartbeat_if_due(&mut self, phase: WasmBuildProgressPhase, elapsed: Duration) {
519 if self.heartbeat_due_in() == Some(Duration::ZERO) {
520 self.emit_heartbeat(phase, elapsed);
521 }
522 }
523
524 fn run_phase<T, F>(&mut self, phase: WasmBuildProgressPhase, operation: F) -> T
525 where
526 T: Send,
527 F: FnOnce() -> T + Send,
528 {
529 if !self.is_observed() || self.config.heartbeat_interval.is_none() {
530 return operation();
531 }
532
533 let started = Instant::now();
534 thread::scope(|scope| {
535 let (finished, completion) = mpsc::sync_channel(0);
536 let worker = scope.spawn(move || {
537 let result = operation();
538 let _ = finished.send(());
539 result
540 });
541 loop {
542 let wait = self
543 .heartbeat_due_in()
544 .expect("observed phase must have a heartbeat interval");
545 match completion.recv_timeout(wait) {
546 Ok(()) | Err(RecvTimeoutError::Disconnected) => {
547 return worker
548 .join()
549 .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
550 }
551 Err(RecvTimeoutError::Timeout) => self.emit_heartbeat(phase, started.elapsed()),
552 }
553 }
554 })
555 }
556}
557
558#[non_exhaustive]
560#[derive(Clone, Copy, Debug, Eq, PartialEq)]
561pub enum WasmBuildPhase {
562 CargoMetadata,
564 CargoIdentity,
566 RustcIdentity,
568 CargoBuild,
570}
571
572#[non_exhaustive]
574#[derive(Debug)]
575pub enum WasmBuildError {
576 InvalidSpec { message: String },
578 Io {
580 operation: &'static str,
581 path: PathBuf,
582 source: io::Error,
583 },
584 CommandSpawn {
586 phase: WasmBuildPhase,
587 program: OsString,
588 source: io::Error,
589 },
590 CommandFailed {
592 phase: WasmBuildPhase,
593 status: ExitStatus,
594 stdout: String,
595 stderr: String,
596 },
597 InvalidMetadata { message: String },
599 InvalidCargoConfiguration { path: PathBuf, message: String },
601 MissingArtifacts { paths: Vec<PathBuf> },
603 InputsChangedDuringBuild {
605 before: InputDigest,
606 after: InputDigest,
607 },
608 FailedBuildCleanup {
610 build_error: Box<Self>,
611 path: PathBuf,
612 source: io::Error,
613 },
614}
615
616impl WasmBuildSpec {
617 #[must_use]
622 pub fn new(
623 workspace_root: &Path,
624 target_dir: &Path,
625 packages: &[&str],
626 profile_target_dir: &str,
627 ) -> Self {
628 Self {
629 workspace_root: workspace_root.to_owned(),
630 target_dir: target_dir.to_owned(),
631 packages: packages
632 .iter()
633 .map(|package| (*package).to_owned())
634 .collect(),
635 profile_target_dir: profile_target_dir.to_owned(),
636 cargo_profile_args: Vec::new(),
637 extra_env: BTreeMap::new(),
638 inherited_env: BTreeSet::new(),
639 additional_inputs: Vec::new(),
640 target: DEFAULT_TARGET.to_owned(),
641 cargo_program: std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()),
642 rustc_program: std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()),
643 cache_mode: WasmBuildCacheMode::Isolated,
644 prune_policy: None,
645 prune_interval: None,
646 shared_incremental_maintenance_config: None,
647 }
648 }
649
650 #[must_use]
652 pub fn with_cargo_profile_args<I, S>(mut self, arguments: I) -> Self
653 where
654 I: IntoIterator<Item = S>,
655 S: AsRef<OsStr>,
656 {
657 self.cargo_profile_args = arguments
658 .into_iter()
659 .map(|argument| argument.as_ref().to_owned())
660 .collect();
661 self
662 }
663
664 #[must_use]
666 pub fn with_extra_env<I, K, V>(mut self, environment: I) -> Self
667 where
668 I: IntoIterator<Item = (K, V)>,
669 K: Into<OsString>,
670 V: Into<OsString>,
671 {
672 self.extra_env = environment
673 .into_iter()
674 .map(|(key, value)| (key.into(), value.into()))
675 .collect();
676 self
677 }
678
679 #[must_use]
684 pub fn with_inherited_env<I, S>(mut self, names: I) -> Self
685 where
686 I: IntoIterator<Item = S>,
687 S: Into<OsString>,
688 {
689 self.inherited_env.extend(names.into_iter().map(Into::into));
690 self
691 }
692
693 #[must_use]
698 pub fn with_additional_inputs<I, P>(mut self, paths: I) -> Self
699 where
700 I: IntoIterator<Item = P>,
701 P: Into<PathBuf>,
702 {
703 self.additional_inputs
704 .extend(paths.into_iter().map(Into::into));
705 self
706 }
707
708 #[must_use]
710 pub fn with_target(mut self, target: &str) -> Self {
711 target.clone_into(&mut self.target);
712 self
713 }
714
715 #[must_use]
717 pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
718 self.cargo_program = program.into();
719 self
720 }
721
722 #[must_use]
724 pub fn with_rustc_program(mut self, program: impl Into<OsString>) -> Self {
725 self.rustc_program = program.into();
726 self
727 }
728
729 #[must_use]
735 pub fn with_shared_incremental_target(mut self, target_dir: impl Into<PathBuf>) -> Self {
736 self.cache_mode = WasmBuildCacheMode::SharedIncremental {
737 target_dir: target_dir.into(),
738 };
739 self
740 }
741
742 #[must_use]
753 pub const fn with_shared_incremental_target_maintenance_at_most_every(
754 mut self,
755 policy: SharedIncrementalTargetPrunePolicy,
756 minimum_interval: Duration,
757 ) -> Self {
758 self.shared_incremental_maintenance_config = Some(
759 SharedIncrementalTargetMaintenanceConfig::new(policy, minimum_interval),
760 );
761 self
762 }
763
764 #[must_use]
770 pub const fn with_shared_incremental_target_maintenance(
771 mut self,
772 config: SharedIncrementalTargetMaintenanceConfig,
773 ) -> Self {
774 self.shared_incremental_maintenance_config = Some(config);
775 self
776 }
777
778 #[must_use]
784 pub const fn with_prune_policy(mut self, policy: ArtifactCachePrunePolicy) -> Self {
785 self.prune_policy = Some(policy);
786 self.prune_interval = None;
787 self
788 }
789
790 #[must_use]
797 pub const fn with_prune_policy_at_most_every(
798 mut self,
799 policy: ArtifactCachePrunePolicy,
800 minimum_interval: Duration,
801 ) -> Self {
802 self.prune_policy = Some(policy);
803 self.prune_interval = Some(minimum_interval);
804 self
805 }
806
807 #[must_use]
809 pub fn workspace_root(&self) -> &Path {
810 &self.workspace_root
811 }
812
813 #[must_use]
815 pub fn target_dir(&self) -> &Path {
816 &self.target_dir
817 }
818
819 #[must_use]
821 pub fn packages(&self) -> &[String] {
822 &self.packages
823 }
824
825 #[must_use]
827 pub const fn cache_mode(&self) -> &WasmBuildCacheMode {
828 &self.cache_mode
829 }
830
831 #[must_use]
833 pub const fn prune_policy(&self) -> Option<ArtifactCachePrunePolicy> {
834 self.prune_policy
835 }
836
837 #[must_use]
839 pub const fn prune_interval(&self) -> Option<Duration> {
840 self.prune_interval
841 }
842
843 #[must_use]
845 pub const fn shared_incremental_target_maintenance(
846 &self,
847 ) -> Option<SharedIncrementalTargetMaintenanceConfig> {
848 self.shared_incremental_maintenance_config
849 }
850}
851
852impl WasmBuildOutcome {
853 #[must_use]
855 pub const fn record(&self) -> &WasmBuildRecord {
856 match self {
857 Self::Built(record) | Self::Reused(record) => record,
858 }
859 }
860
861 #[must_use]
863 pub const fn is_reused(&self) -> bool {
864 matches!(self, Self::Reused(_))
865 }
866}
867
868impl WasmBuildRecord {
869 #[must_use]
871 pub const fn fingerprint(&self) -> InputDigest {
872 self.fingerprint
873 }
874
875 #[must_use]
877 pub const fn input_digest(&self) -> InputDigest {
878 self.input_digest
879 }
880
881 #[must_use]
887 pub fn exact_cache_path(&self) -> &Path {
888 &self.exact_cache_path
889 }
890
891 #[must_use]
893 pub fn artifacts(&self) -> &[PathBuf] {
894 &self.artifacts
895 }
896
897 #[must_use]
899 pub const fn timings(&self) -> WasmBuildTimings {
900 self.timings
901 }
902
903 #[must_use]
905 pub const fn maintenance(&self) -> Option<&ArtifactCacheMaintenance> {
906 self.maintenance.as_ref()
907 }
908
909 #[must_use]
911 pub const fn shared_incremental_maintenance(
912 &self,
913 ) -> Option<&SharedIncrementalTargetMaintenanceOutcome> {
914 self.shared_incremental_maintenance.as_ref()
915 }
916}
917
918impl WasmBuildTimings {
919 #[must_use]
921 pub const fn lock_wait(self) -> Duration {
922 self.lock_wait
923 }
924
925 #[must_use]
927 pub const fn shared_incremental_lock_wait(self) -> Option<Duration> {
928 self.shared_incremental_lock_wait
929 }
930
931 #[must_use]
933 pub const fn input_resolution(self) -> WasmInputResolutionTimings {
934 self.input_resolution
935 }
936
937 #[must_use]
939 pub const fn cargo_build(self) -> Option<Duration> {
940 self.cargo_build
941 }
942
943 #[must_use]
945 pub const fn cache_maintenance(self) -> Option<Duration> {
946 self.cache_maintenance
947 }
948
949 #[must_use]
951 pub const fn total(self) -> Duration {
952 self.total
953 }
954
955 pub(super) const fn saturating_add(self, other: Self) -> Self {
956 let mut input_resolution = self.input_resolution;
957 input_resolution.include(other.input_resolution);
958 Self {
959 lock_wait: self.lock_wait.saturating_add(other.lock_wait),
960 shared_incremental_lock_wait: saturating_add_optional_duration(
961 self.shared_incremental_lock_wait,
962 other.shared_incremental_lock_wait,
963 ),
964 input_resolution,
965 cargo_build: saturating_add_optional_duration(self.cargo_build, other.cargo_build),
966 cache_maintenance: saturating_add_optional_duration(
967 self.cache_maintenance,
968 other.cache_maintenance,
969 ),
970 total: self.total.saturating_add(other.total),
971 }
972 }
973}
974
975impl WasmInputResolutionTimings {
976 #[must_use]
978 pub const fn tool_identity(self) -> Duration {
979 self.tool_identity
980 }
981
982 #[must_use]
984 pub const fn cargo_metadata(self) -> Duration {
985 self.cargo_metadata
986 }
987
988 #[must_use]
990 pub const fn input_discovery(self) -> Duration {
991 self.input_discovery
992 }
993
994 #[must_use]
996 pub const fn content_hashing(self) -> Duration {
997 self.content_hashing
998 }
999
1000 #[must_use]
1002 pub const fn total(self) -> Duration {
1003 self.total
1004 }
1005
1006 const fn include(&mut self, other: Self) {
1007 self.tool_identity = self.tool_identity.saturating_add(other.tool_identity);
1008 self.cargo_metadata = self.cargo_metadata.saturating_add(other.cargo_metadata);
1009 self.input_discovery = self.input_discovery.saturating_add(other.input_discovery);
1010 self.content_hashing = self.content_hashing.saturating_add(other.content_hashing);
1011 self.total = self.total.saturating_add(other.total);
1012 }
1013}
1014
1015impl CargoBuildInput {
1016 #[must_use]
1018 pub fn label(&self) -> &Path {
1019 &self.label
1020 }
1021
1022 #[must_use]
1024 pub fn path(&self) -> &Path {
1025 &self.path
1026 }
1027}
1028
1029impl ResolvedCargoBuildInputs {
1030 #[must_use]
1032 pub const fn fingerprint(&self) -> InputDigest {
1033 self.fingerprint
1034 }
1035
1036 #[must_use]
1041 pub const fn input_digest(&self) -> InputDigest {
1042 self.input_digest
1043 }
1044
1045 #[must_use]
1050 pub const fn validation_digest(&self) -> InputDigest {
1051 self.validation_digest
1052 }
1053
1054 #[must_use]
1056 pub fn inputs(&self) -> &[CargoBuildInput] {
1057 &self.inputs
1058 }
1059
1060 #[must_use]
1065 pub fn exclusions(&self) -> &[PathBuf] {
1066 &self.exclusions
1067 }
1068
1069 #[must_use]
1071 pub const fn timings(&self) -> WasmInputResolutionTimings {
1072 self.timings
1073 }
1074
1075 pub fn is_current(&self, spec: &WasmBuildSpec) -> Result<bool, WasmBuildError> {
1077 resolve_cargo_build_inputs(spec).map(|current| current.fingerprint == self.fingerprint)
1078 }
1079
1080 pub fn is_content_current(&self) -> Result<bool, WasmBuildError> {
1087 self.current_validation_digest()
1088 .map(|current| current == self.validation_digest)
1089 }
1090
1091 pub(super) fn current_validation_digest(&self) -> Result<InputDigest, WasmBuildError> {
1092 let inputs = self
1093 .inputs
1094 .iter()
1095 .map(|input| (input.label.clone(), input.path.clone()))
1096 .collect::<Vec<_>>();
1097 digest_labeled_paths_composable(
1098 "wasm-source-inputs-v1",
1099 &inputs,
1100 &self.exclusions,
1101 &mut LabeledPathDigestCache::default(),
1102 )
1103 .map_err(|source| WasmBuildError::Io {
1104 operation: "rehash resolved Cargo build inputs",
1105 path: self
1106 .inputs
1107 .first()
1108 .map_or_else(PathBuf::new, |input| input.path.clone()),
1109 source,
1110 })
1111 }
1112}
1113
1114impl<'a> WasmBuildBatchInputResolver<'a> {
1115 pub(super) fn new(specs: &'a [WasmBuildSpec]) -> Self {
1116 let mut keys = Vec::<BatchResolutionKey>::new();
1117 let mut groups = Vec::<BatchResolutionGroup>::new();
1118 let mut group_by_index = Vec::with_capacity(specs.len());
1119 for (index, spec) in specs.iter().enumerate() {
1120 let key = BatchResolutionKey::for_spec(spec);
1121 let group = keys
1122 .iter()
1123 .position(|candidate| *candidate == key)
1124 .unwrap_or_else(|| {
1125 keys.push(key);
1126 groups.push(BatchResolutionGroup {
1127 indexes: Vec::new(),
1128 });
1129 groups.len() - 1
1130 });
1131 groups[group].indexes.push(index);
1132 group_by_index.push(group);
1133 }
1134 Self {
1135 specs,
1136 groups,
1137 group_by_index,
1138 resolved: std::iter::repeat_with(|| None).take(specs.len()).collect(),
1139 metrics: WasmBuildBatchInputMetrics::default(),
1140 }
1141 }
1142
1143 pub(super) const fn metrics(&self) -> WasmBuildBatchInputMetrics {
1144 self.metrics
1145 }
1146
1147 fn resolve(
1148 &mut self,
1149 index: usize,
1150 progress: &mut ProgressReporter<'_>,
1151 ) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1152 if self.resolved[index].is_none() {
1153 self.resolve_group(index, progress)?;
1154 }
1155 self.resolved[index]
1156 .take()
1157 .expect("resolved batch input must be populated")
1158 }
1159
1160 fn resolve_group(
1161 &mut self,
1162 active_index: usize,
1163 progress: &mut ProgressReporter<'_>,
1164 ) -> Result<(), WasmBuildError> {
1165 let total_started = Instant::now();
1166 let indexes = self.groups[self.group_by_index[active_index]]
1167 .indexes
1168 .clone();
1169 let active = &self.specs[active_index];
1170
1171 let (cargo_identity, rustc_identity, tool_identity) =
1172 resolve_batch_tool_identity(active, progress)?;
1173
1174 let metadata_started = Instant::now();
1175 let metadata = progress.run_phase(WasmBuildProgressPhase::CargoMetadata, || {
1176 cargo_metadata(active)
1177 })?;
1178 let cargo_metadata = metadata_started.elapsed();
1179
1180 let discovery_started = Instant::now();
1181 let mut discovered = Vec::new();
1182 for index in indexes {
1183 if self.resolved[index].is_some() || validate_spec(&self.specs[index]).is_err() {
1184 continue;
1185 }
1186 let spec = &self.specs[index];
1187 let result = (|| {
1188 let inputs = resolve_local_inputs(spec, &metadata)?;
1189 validate_shared_incremental_target_boundary(spec, &inputs.validation_inputs)?;
1190 let exclusions = source_exclusions(spec, &inputs.validation_inputs);
1191 Ok::<_, WasmBuildError>((inputs, exclusions))
1192 })();
1193 match result {
1194 Ok((inputs, exclusions)) => discovered.push((index, inputs, exclusions)),
1195 Err(error) => self.resolved[index] = Some(Err(error)),
1196 }
1197 }
1198 let input_discovery = discovery_started.elapsed();
1199
1200 let hashing_started = Instant::now();
1201 let resolved_inputs = progress.run_phase(WasmBuildProgressPhase::ContentHashing, || {
1202 let mut cache = LabeledPathDigestCache::default();
1203 discovered
1204 .into_iter()
1205 .map(|(index, inputs, exclusions)| {
1206 let (input_digest, validation_digest) = digest_resolved_local_inputs(
1207 &inputs,
1208 &exclusions,
1209 &mut cache,
1210 &active.workspace_root,
1211 "hash batched Wasm build inputs",
1212 "hash batched semantic Wasm build inputs",
1213 )?;
1214 Ok::<_, WasmBuildError>((
1215 index,
1216 inputs.validation_inputs,
1217 exclusions,
1218 input_digest,
1219 validation_digest,
1220 ))
1221 })
1222 .collect::<Result<Vec<_>, _>>()
1223 })?;
1224 let content_hashing = hashing_started.elapsed();
1225 let timings = WasmInputResolutionTimings {
1226 tool_identity,
1227 cargo_metadata,
1228 input_discovery,
1229 content_hashing,
1230 total: total_started.elapsed(),
1231 };
1232 let resolved_count = resolved_inputs.len();
1233 if resolved_count > 0 {
1234 self.metrics.runs += 1;
1235 self.metrics.reuses += resolved_count.saturating_sub(1);
1236 }
1237 let timing_index = resolved_inputs
1238 .iter()
1239 .any(|(index, ..)| *index == active_index)
1240 .then_some(active_index)
1241 .or_else(|| resolved_inputs.first().map(|(index, ..)| *index));
1242 for (index, inputs, exclusions, input_digest, validation_digest) in resolved_inputs {
1243 let spec = &self.specs[index];
1244 self.resolved[index] = Some(Ok(ResolvedCargoBuildInputs {
1245 fingerprint: finish_build_fingerprint(
1246 spec,
1247 &cargo_identity,
1248 &rustc_identity,
1249 input_digest,
1250 ),
1251 input_digest,
1252 validation_digest,
1253 inputs: inputs
1254 .into_iter()
1255 .map(|(label, path)| CargoBuildInput { label, path })
1256 .collect(),
1257 exclusions,
1258 timings: if Some(index) == timing_index {
1259 timings
1260 } else {
1261 WasmInputResolutionTimings::default()
1262 },
1263 }));
1264 }
1265 Ok(())
1266 }
1267}
1268
1269fn resolve_batch_tool_identity(
1270 spec: &WasmBuildSpec,
1271 progress: &mut ProgressReporter<'_>,
1272) -> Result<(Vec<u8>, Vec<u8>, Duration), WasmBuildError> {
1273 let started = Instant::now();
1274 let cargo_identity = progress.run_phase(WasmBuildProgressPhase::CargoIdentity, || {
1275 command_identity(
1276 spec,
1277 WasmBuildPhase::CargoIdentity,
1278 &spec.cargo_program,
1279 &["--version", "--verbose"],
1280 )
1281 })?;
1282 let rustc_program = spec
1283 .extra_env
1284 .get(OsStr::new("RUSTC"))
1285 .unwrap_or(&spec.rustc_program);
1286 let rustc_identity = progress.run_phase(WasmBuildProgressPhase::RustcIdentity, || {
1287 command_identity(spec, WasmBuildPhase::RustcIdentity, rustc_program, &["-vV"])
1288 })?;
1289 Ok((cargo_identity, rustc_identity, started.elapsed()))
1290}
1291
1292impl BatchResolutionKey {
1293 fn for_spec(spec: &WasmBuildSpec) -> Self {
1294 Self {
1295 workspace_root: spec.workspace_root.clone(),
1296 cargo_program: spec.cargo_program.clone(),
1297 rustc_program: spec
1298 .extra_env
1299 .get(OsStr::new("RUSTC"))
1300 .unwrap_or(&spec.rustc_program)
1301 .clone(),
1302 metadata_arguments: metadata_arguments(&spec.cargo_profile_args),
1303 environment: effective_environment(spec),
1304 }
1305 }
1306}
1307
1308impl SharedIncrementalTargetInspection {
1309 #[must_use]
1311 pub fn target_dir(&self) -> &Path {
1312 &self.target_dir
1313 }
1314
1315 #[must_use]
1317 pub const fn logical_size_bytes(&self) -> u64 {
1318 self.logical_size_bytes
1319 }
1320
1321 #[must_use]
1323 pub const fn last_used(&self) -> SystemTime {
1324 self.last_used
1325 }
1326
1327 #[must_use]
1329 pub const fn lock_wait(&self) -> Duration {
1330 self.lock_wait
1331 }
1332}
1333
1334impl SharedIncrementalTargetPrunePolicy {
1335 #[must_use]
1337 pub const fn new() -> Self {
1338 Self {
1339 max_age: None,
1340 max_size_bytes: None,
1341 }
1342 }
1343
1344 #[must_use]
1346 pub const fn with_max_age(mut self, max_age: Duration) -> Self {
1347 self.max_age = Some(max_age);
1348 self
1349 }
1350
1351 #[must_use]
1353 pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
1354 self.max_size_bytes = Some(bytes);
1355 self
1356 }
1357
1358 #[must_use]
1360 pub const fn max_age(self) -> Option<Duration> {
1361 self.max_age
1362 }
1363
1364 #[must_use]
1366 pub const fn max_size_bytes(self) -> Option<u64> {
1367 self.max_size_bytes
1368 }
1369
1370 fn maintenance_identity(self) -> String {
1371 format!(
1372 "age={:?};size={:?}",
1373 self.max_age.map(|duration| duration.as_nanos()),
1374 self.max_size_bytes
1375 )
1376 }
1377}
1378
1379impl SharedIncrementalTargetMaintenanceConfig {
1380 #[must_use]
1382 pub const fn new(
1383 policy: SharedIncrementalTargetPrunePolicy,
1384 minimum_interval: Duration,
1385 ) -> Self {
1386 Self {
1387 policy,
1388 minimum_interval,
1389 failure_mode: SharedIncrementalTargetMaintenanceFailureMode::Strict,
1390 }
1391 }
1392
1393 #[must_use]
1395 pub const fn with_failure_mode(
1396 mut self,
1397 failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
1398 ) -> Self {
1399 self.failure_mode = failure_mode;
1400 self
1401 }
1402
1403 #[must_use]
1405 pub const fn policy(self) -> SharedIncrementalTargetPrunePolicy {
1406 self.policy
1407 }
1408
1409 #[must_use]
1411 pub const fn minimum_interval(self) -> Duration {
1412 self.minimum_interval
1413 }
1414
1415 #[must_use]
1417 pub const fn failure_mode(self) -> SharedIncrementalTargetMaintenanceFailureMode {
1418 self.failure_mode
1419 }
1420}
1421
1422impl SharedIncrementalTargetMaintenance {
1423 #[must_use]
1425 pub fn target_dir(&self) -> &Path {
1426 &self.target_dir
1427 }
1428
1429 #[must_use]
1431 pub const fn logical_size_bytes_before(&self) -> u64 {
1432 self.logical_size_bytes_before
1433 }
1434
1435 #[must_use]
1437 pub const fn logical_size_bytes_after(&self) -> u64 {
1438 self.logical_size_bytes_after
1439 }
1440
1441 #[must_use]
1443 pub const fn last_used_before(&self) -> SystemTime {
1444 self.last_used_before
1445 }
1446
1447 #[must_use]
1449 pub const fn was_cleared(&self) -> bool {
1450 self.cleared
1451 }
1452
1453 #[must_use]
1455 pub const fn lock_wait(&self) -> Duration {
1456 self.lock_wait
1457 }
1458
1459 #[must_use]
1461 pub const fn maintenance(&self) -> Duration {
1462 self.maintenance
1463 }
1464}
1465
1466impl std::fmt::Display for SharedIncrementalTargetMaintenance {
1467 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1468 write!(
1469 formatter,
1470 "target={} action={} bytes={}=>{} lock={:?} maintenance={:?}",
1471 self.target_dir.display(),
1472 if self.cleared { "cleared" } else { "retained" },
1473 self.logical_size_bytes_before,
1474 self.logical_size_bytes_after,
1475 self.lock_wait,
1476 self.maintenance,
1477 )
1478 }
1479}
1480
1481impl SharedIncrementalTargetMaintenanceOutcome {
1482 #[must_use]
1484 pub fn target_dir(&self) -> &Path {
1485 match self {
1486 Self::Missing { target_dir }
1487 | Self::Skipped { target_dir, .. }
1488 | Self::Failed { target_dir, .. } => target_dir,
1489 Self::Performed { maintenance, .. } => maintenance.target_dir(),
1490 }
1491 }
1492
1493 #[must_use]
1495 pub const fn maintenance(&self) -> Option<&SharedIncrementalTargetMaintenance> {
1496 match self {
1497 Self::Performed { maintenance, .. } => Some(maintenance),
1498 Self::Missing { .. } | Self::Skipped { .. } | Self::Failed { .. } => None,
1499 }
1500 }
1501
1502 #[must_use]
1504 pub const fn was_performed(&self) -> bool {
1505 matches!(self, Self::Performed { .. })
1506 }
1507
1508 #[must_use]
1510 pub const fn lock_wait(&self) -> Option<Duration> {
1511 match self {
1512 Self::Missing { .. } => None,
1513 Self::Skipped { lock_wait, .. } | Self::Failed { lock_wait, .. } => Some(*lock_wait),
1514 Self::Performed { maintenance, .. } => Some(maintenance.lock_wait()),
1515 }
1516 }
1517
1518 #[must_use]
1520 pub const fn schedule_check(&self) -> Option<Duration> {
1521 match self {
1522 Self::Missing { .. } | Self::Failed { .. } => None,
1523 Self::Skipped { schedule_check, .. } | Self::Performed { schedule_check, .. } => {
1524 Some(*schedule_check)
1525 }
1526 }
1527 }
1528
1529 #[must_use]
1531 pub fn failure_message(&self) -> Option<&str> {
1532 match self {
1533 Self::Failed { message, .. } => Some(message),
1534 Self::Missing { .. } | Self::Skipped { .. } | Self::Performed { .. } => None,
1535 }
1536 }
1537}
1538
1539impl std::fmt::Display for SharedIncrementalTargetMaintenanceOutcome {
1540 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1541 match self {
1542 Self::Missing { target_dir } => {
1543 write!(formatter, "target={} action=missing", target_dir.display())
1544 }
1545 Self::Skipped {
1546 target_dir,
1547 lock_wait,
1548 schedule_check,
1549 } => write!(
1550 formatter,
1551 "target={} action=skipped lock={lock_wait:?} schedule={schedule_check:?}",
1552 target_dir.display(),
1553 ),
1554 Self::Performed {
1555 maintenance,
1556 schedule_check,
1557 } => write!(formatter, "{maintenance} schedule={schedule_check:?}"),
1558 Self::Failed {
1559 target_dir,
1560 lock_wait,
1561 message,
1562 } => write!(
1563 formatter,
1564 "target={} action=failed lock={lock_wait:?} error={message}",
1565 target_dir.display(),
1566 ),
1567 }
1568 }
1569}
1570
1571impl std::fmt::Display for WasmBuildTimings {
1572 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1573 write!(
1574 formatter,
1575 "total={:?} lock={:?} shared_lock={:?} inputs={:?} cargo={:?} maintenance={:?}",
1576 self.total,
1577 self.lock_wait,
1578 self.shared_incremental_lock_wait,
1579 self.input_resolution.total,
1580 self.cargo_build,
1581 self.cache_maintenance,
1582 )
1583 }
1584}
1585
1586impl std::fmt::Display for WasmBuildOutcome {
1587 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1588 let state = if self.is_reused() { "reused" } else { "built" };
1589 write!(
1590 formatter,
1591 "{state} fingerprint={} artifacts={} {}",
1592 self.record().fingerprint,
1593 self.record().artifacts.len(),
1594 self.record().timings,
1595 )?;
1596 if let Some(maintenance) = self.record().shared_incremental_maintenance() {
1597 write!(formatter, " shared_maintenance=({maintenance})")?;
1598 }
1599 Ok(())
1600 }
1601}
1602
1603pub fn resolve_cargo_build_inputs(
1608 spec: &WasmBuildSpec,
1609) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1610 validate_spec(spec)?;
1611 build_fingerprint(spec)
1612}
1613
1614pub fn inspect_shared_incremental_target(
1619 spec: &WasmBuildSpec,
1620) -> Result<Option<SharedIncrementalTargetInspection>, WasmBuildError> {
1621 if !shared_incremental_target_exists(spec, "inspect shared incremental Cargo target")? {
1622 return Ok(None);
1623 }
1624
1625 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
1626 let logical_size_bytes =
1627 directory_logical_size(&canonical).map_err(|source| WasmBuildError::Io {
1628 operation: "measure shared incremental Cargo target",
1629 path: canonical.clone(),
1630 source,
1631 })?;
1632 let last_used = cache_entry_last_used(&canonical).map_err(|source| WasmBuildError::Io {
1633 operation: "read shared incremental Cargo target use time",
1634 path: canonical.clone(),
1635 source,
1636 })?;
1637 Ok(Some(SharedIncrementalTargetInspection {
1638 target_dir: canonical,
1639 logical_size_bytes,
1640 last_used,
1641 lock_wait,
1642 }))
1643}
1644
1645pub fn maintain_shared_incremental_target(
1659 spec: &WasmBuildSpec,
1660 policy: SharedIncrementalTargetPrunePolicy,
1661) -> Result<Option<SharedIncrementalTargetMaintenance>, WasmBuildError> {
1662 if !shared_incremental_target_exists(
1663 spec,
1664 "inspect shared incremental Cargo target before maintenance",
1665 )? {
1666 return Ok(None);
1667 }
1668
1669 let _ = resolve_cargo_build_inputs(spec)?;
1673 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
1674 maintain_shared_incremental_target_locked(&canonical, policy, lock_wait).map(Some)
1675}
1676
1677pub fn maintain_shared_incremental_target_at_most_every(
1691 spec: &WasmBuildSpec,
1692 policy: SharedIncrementalTargetPrunePolicy,
1693 minimum_interval: Duration,
1694) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
1695 let target_dir =
1696 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
1697 message: "shared incremental target is not configured".to_owned(),
1698 })?;
1699 if !shared_incremental_target_exists(
1700 spec,
1701 "inspect shared incremental Cargo target before scheduled maintenance",
1702 )? {
1703 return Ok(SharedIncrementalTargetMaintenanceOutcome::Missing { target_dir });
1704 }
1705
1706 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
1707 let schedule = schedule_shared_incremental_target_maintenance(
1708 &canonical,
1709 policy,
1710 minimum_interval,
1711 lock_wait,
1712 )?;
1713 let schedule = match schedule {
1714 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => return Ok(outcome),
1715 SharedIncrementalTargetMaintenanceSchedule::Due(due) => due,
1716 };
1717
1718 let _ = resolve_cargo_build_inputs(spec)?;
1721 perform_due_shared_incremental_target_maintenance(&canonical, policy, lock_wait, schedule)
1722}
1723
1724enum SharedIncrementalTargetMaintenanceSchedule {
1725 Skipped(SharedIncrementalTargetMaintenanceOutcome),
1726 Due(DueSharedIncrementalTargetMaintenance),
1727}
1728
1729struct DueSharedIncrementalTargetMaintenance {
1730 schedule_root: PathBuf,
1731 maintenance_identity: String,
1732 schedule_check: Duration,
1733}
1734
1735fn schedule_shared_incremental_target_maintenance(
1736 canonical: &Path,
1737 policy: SharedIncrementalTargetPrunePolicy,
1738 minimum_interval: Duration,
1739 lock_wait: Duration,
1740) -> Result<SharedIncrementalTargetMaintenanceSchedule, WasmBuildError> {
1741 let schedule_root = canonical.join(".ic-testkit");
1742 let maintenance_identity = policy.maintenance_identity();
1743 let schedule_started = Instant::now();
1744 let due = cache_maintenance_due(
1745 &schedule_root,
1746 Some(minimum_interval),
1747 &maintenance_identity,
1748 )
1749 .map_err(wasm_cache_fs_error)?;
1750 let schedule_check = schedule_started.elapsed();
1751 if !due {
1752 return Ok(SharedIncrementalTargetMaintenanceSchedule::Skipped(
1753 SharedIncrementalTargetMaintenanceOutcome::Skipped {
1754 target_dir: canonical.to_owned(),
1755 lock_wait,
1756 schedule_check,
1757 },
1758 ));
1759 }
1760 Ok(SharedIncrementalTargetMaintenanceSchedule::Due(
1761 DueSharedIncrementalTargetMaintenance {
1762 schedule_root,
1763 maintenance_identity,
1764 schedule_check,
1765 },
1766 ))
1767}
1768
1769fn perform_due_shared_incremental_target_maintenance(
1770 canonical: &Path,
1771 policy: SharedIncrementalTargetPrunePolicy,
1772 lock_wait: Duration,
1773 due: DueSharedIncrementalTargetMaintenance,
1774) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
1775 let DueSharedIncrementalTargetMaintenance {
1776 schedule_root,
1777 maintenance_identity,
1778 schedule_check,
1779 } = due;
1780 let maintenance = maintain_shared_incremental_target_locked(canonical, policy, lock_wait)?;
1781 record_cache_maintenance(&schedule_root, &maintenance_identity).map_err(wasm_cache_fs_error)?;
1782 Ok(SharedIncrementalTargetMaintenanceOutcome::Performed {
1783 maintenance,
1784 schedule_check,
1785 })
1786}
1787
1788fn maintain_shared_incremental_target_locked(
1789 canonical: &Path,
1790 policy: SharedIncrementalTargetPrunePolicy,
1791 lock_wait: Duration,
1792) -> Result<SharedIncrementalTargetMaintenance, WasmBuildError> {
1793 let started = Instant::now();
1794 let logical_size_bytes_before =
1795 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
1796 operation: "measure shared incremental Cargo target before maintenance",
1797 path: canonical.to_owned(),
1798 source,
1799 })?;
1800 let last_used_before =
1801 cache_entry_last_used(canonical).map_err(|source| WasmBuildError::Io {
1802 operation: "read shared incremental Cargo target use time before maintenance",
1803 path: canonical.to_owned(),
1804 source,
1805 })?;
1806 let expired = policy.max_age.is_some_and(|max_age| {
1807 SystemTime::now()
1808 .duration_since(last_used_before)
1809 .is_ok_and(|age| age > max_age)
1810 });
1811 let oversized = policy
1812 .max_size_bytes
1813 .is_some_and(|max_size_bytes| logical_size_bytes_before > max_size_bytes);
1814 let cleared = expired || oversized;
1815 if cleared {
1816 clear_shared_incremental_target_contents(canonical)?;
1817 record_cache_entry_use(canonical)?;
1818 }
1819 let logical_size_bytes_after = if cleared {
1820 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
1821 operation: "measure shared incremental Cargo target after maintenance",
1822 path: canonical.to_owned(),
1823 source,
1824 })?
1825 } else {
1826 logical_size_bytes_before
1827 };
1828 Ok(SharedIncrementalTargetMaintenance {
1829 target_dir: canonical.to_owned(),
1830 logical_size_bytes_before,
1831 logical_size_bytes_after,
1832 last_used_before,
1833 cleared,
1834 lock_wait,
1835 maintenance: started.elapsed(),
1836 })
1837}
1838
1839fn clear_shared_incremental_target_contents(target_dir: &Path) -> Result<(), WasmBuildError> {
1840 let entries = fs::read_dir(target_dir).map_err(|source| WasmBuildError::Io {
1841 operation: "read shared incremental Cargo target for maintenance",
1842 path: target_dir.to_owned(),
1843 source,
1844 })?;
1845 for entry in entries {
1846 let path = entry
1847 .map_err(|source| WasmBuildError::Io {
1848 operation: "read shared incremental Cargo target entry for maintenance",
1849 path: target_dir.to_owned(),
1850 source,
1851 })?
1852 .path();
1853 let preserved = path
1854 .file_name()
1855 .is_some_and(|name| name == ".ic-testkit" || name == "CACHEDIR.TAG");
1856 if !preserved {
1857 remove_path_if_present(&path).map_err(|source| WasmBuildError::Io {
1858 operation: "clear shared incremental Cargo target entry",
1859 path,
1860 source,
1861 })?;
1862 }
1863 }
1864 Ok(())
1865}
1866
1867pub fn build_wasm_canisters_cached(
1874 spec: &WasmBuildSpec,
1875) -> Result<WasmBuildOutcome, WasmBuildError> {
1876 build_wasm_canisters_cached_internal(spec, &mut ProgressReporter::silent(), None)
1877}
1878
1879pub(super) fn build_wasm_canisters_cached_in_batch(
1880 spec: &WasmBuildSpec,
1881 index: usize,
1882 resolver: &mut WasmBuildBatchInputResolver<'_>,
1883) -> Result<WasmBuildOutcome, WasmBuildError> {
1884 build_wasm_canisters_cached_internal(
1885 spec,
1886 &mut ProgressReporter::silent(),
1887 Some((resolver, index)),
1888 )
1889}
1890
1891pub fn build_wasm_canisters_cached_with_progress<F>(
1900 spec: &WasmBuildSpec,
1901 config: WasmBuildProgressConfig,
1902 mut observer: F,
1903) -> Result<WasmBuildOutcome, WasmBuildError>
1904where
1905 F: FnMut(WasmBuildProgressEvent),
1906{
1907 if config.heartbeat_interval == Some(Duration::ZERO) {
1908 return Err(WasmBuildError::InvalidSpec {
1909 message: "Wasm build progress heartbeat interval must be greater than zero".to_owned(),
1910 });
1911 }
1912 build_wasm_canisters_cached_internal(
1913 spec,
1914 &mut ProgressReporter::observed(config, &mut observer),
1915 None,
1916 )
1917}
1918
1919pub(super) fn build_wasm_canisters_cached_in_batch_with_progress<F>(
1920 spec: &WasmBuildSpec,
1921 index: usize,
1922 resolver: &mut WasmBuildBatchInputResolver<'_>,
1923 config: WasmBuildProgressConfig,
1924 mut observer: F,
1925) -> Result<WasmBuildOutcome, WasmBuildError>
1926where
1927 F: FnMut(WasmBuildProgressEvent),
1928{
1929 if config.heartbeat_interval == Some(Duration::ZERO) {
1930 return Err(WasmBuildError::InvalidSpec {
1931 message: "Wasm build progress heartbeat interval must be greater than zero".to_owned(),
1932 });
1933 }
1934 build_wasm_canisters_cached_internal(
1935 spec,
1936 &mut ProgressReporter::observed(config, &mut observer),
1937 Some((resolver, index)),
1938 )
1939}
1940
1941fn build_wasm_canisters_cached_internal(
1942 spec: &WasmBuildSpec,
1943 progress: &mut ProgressReporter<'_>,
1944 mut batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_>, usize)>,
1945) -> Result<WasmBuildOutcome, WasmBuildError> {
1946 let total_started = Instant::now();
1947 validate_spec(spec)?;
1948 progress.emit(WasmBuildProgressEvent::Started);
1949 if spec.shared_incremental_maintenance_config.is_some() {
1950 let outcome = build_wasm_canisters_cached_with_scheduled_shared_maintenance(
1951 spec,
1952 total_started,
1953 progress,
1954 batch_resolution.take(),
1955 )?;
1956 emit_finished_progress(&outcome, progress);
1957 return Ok(outcome);
1958 }
1959 let (cache_lock, first_lock_wait) =
1960 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
1961 ensure_cache_directory_tag(&spec.target_dir)?;
1962
1963 let resolved = resolve_initial_inputs(spec, batch_resolution.take(), progress)?;
1964 if let Some(outcome) = try_reuse_wasm_artifacts(
1965 spec,
1966 &resolved,
1967 first_lock_wait,
1968 &SharedIncrementalAcquisitionContext::default(),
1969 total_started,
1970 progress,
1971 )? {
1972 emit_finished_progress(&outcome, progress);
1973 return Ok(outcome);
1974 }
1975 progress.emit(WasmBuildProgressEvent::CacheMiss {
1976 fingerprint: resolved.fingerprint,
1977 });
1978
1979 let outcome = match &spec.cache_mode {
1980 WasmBuildCacheMode::Isolated => {
1981 let cache_entry = cache_entry_directory(spec, resolved.fingerprint);
1982 build_wasm_cache_miss(
1983 spec,
1984 resolved,
1985 first_lock_wait,
1986 SharedIncrementalAcquisitionContext::default(),
1987 cache_entry,
1988 total_started,
1989 progress,
1990 )
1991 }
1992 WasmBuildCacheMode::SharedIncremental { .. } => {
1993 drop(cache_lock);
1994 let configured_target = shared_incremental_target(spec)
1995 .expect("shared cache mode must resolve a shared Cargo target");
1996 progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted {
1997 target_dir: configured_target,
1998 });
1999 let (shared_lock, shared_lock_wait, shared_target) =
2000 lock_shared_incremental_target_with_progress(spec, progress)?;
2001 progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
2002 target_dir: shared_target.clone(),
2003 wait: shared_lock_wait,
2004 });
2005 let (_cache_lock, second_lock_wait) =
2006 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2007 ensure_cache_directory_tag(&spec.target_dir)?;
2008
2009 let mut current = resolve_inputs_with_progress(spec, progress)?;
2010 current.timings.include(resolved.timings);
2011 let lock_wait = first_lock_wait.saturating_add(second_lock_wait);
2012 let shared_incremental = SharedIncrementalAcquisitionContext {
2013 lock_wait: Some(shared_lock_wait),
2014 maintenance: None,
2015 };
2016 if let Some(outcome) = try_reuse_wasm_artifacts(
2017 spec,
2018 ¤t,
2019 lock_wait,
2020 &shared_incremental,
2021 total_started,
2022 progress,
2023 )? {
2024 emit_finished_progress(&outcome, progress);
2025 return Ok(outcome);
2026 }
2027
2028 let outcome = build_wasm_cache_miss(
2029 spec,
2030 current,
2031 lock_wait,
2032 shared_incremental,
2033 shared_target,
2034 total_started,
2035 progress,
2036 );
2037 drop(shared_lock);
2038 outcome
2039 }
2040 }?;
2041 emit_finished_progress(&outcome, progress);
2042 Ok(outcome)
2043}
2044
2045fn build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2046 spec: &WasmBuildSpec,
2047 total_started: Instant,
2048 progress: &mut ProgressReporter<'_>,
2049 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_>, usize)>,
2050) -> Result<WasmBuildOutcome, WasmBuildError> {
2051 let configured_target = shared_incremental_target(spec)
2052 .expect("validated scheduled maintenance must have a shared Cargo target");
2053 progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted {
2054 target_dir: configured_target,
2055 });
2056 let (_shared_lock, shared_lock_wait, shared_target) =
2057 lock_shared_incremental_target_with_progress(spec, progress)?;
2058 progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
2059 target_dir: shared_target.clone(),
2060 wait: shared_lock_wait,
2061 });
2062 let (_cache_lock, lock_wait) = lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2063 ensure_cache_directory_tag(&spec.target_dir)?;
2064
2065 let resolved = resolve_initial_inputs(spec, batch_resolution, progress)?;
2068 let shared_maintenance = perform_configured_shared_incremental_target_maintenance(
2069 spec,
2070 &shared_target,
2071 shared_lock_wait,
2072 progress,
2073 )?;
2074 let shared_incremental = SharedIncrementalAcquisitionContext {
2075 lock_wait: Some(shared_lock_wait),
2076 maintenance: Some(shared_maintenance),
2077 };
2078 if let Some(outcome) = try_reuse_wasm_artifacts(
2079 spec,
2080 &resolved,
2081 lock_wait,
2082 &shared_incremental,
2083 total_started,
2084 progress,
2085 )? {
2086 return Ok(outcome);
2087 }
2088 progress.emit(WasmBuildProgressEvent::CacheMiss {
2089 fingerprint: resolved.fingerprint,
2090 });
2091 build_wasm_cache_miss(
2092 spec,
2093 resolved,
2094 lock_wait,
2095 shared_incremental,
2096 shared_target,
2097 total_started,
2098 progress,
2099 )
2100}
2101
2102fn perform_configured_shared_incremental_target_maintenance(
2103 spec: &WasmBuildSpec,
2104 shared_target: &Path,
2105 lock_wait: Duration,
2106 progress: &mut ProgressReporter<'_>,
2107) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2108 let config = spec
2109 .shared_incremental_maintenance_config
2110 .expect("configured shared-target maintenance must have settings");
2111 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceStarted {
2112 target_dir: shared_target.to_owned(),
2113 });
2114 let result = progress.run_phase(WasmBuildProgressPhase::SharedTargetMaintenance, || {
2115 let schedule = schedule_shared_incremental_target_maintenance(
2116 shared_target,
2117 config.policy,
2118 config.minimum_interval,
2119 lock_wait,
2120 )?;
2121 match schedule {
2122 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => Ok(outcome),
2123 SharedIncrementalTargetMaintenanceSchedule::Due(due) => {
2124 perform_due_shared_incremental_target_maintenance(
2125 shared_target,
2126 config.policy,
2127 lock_wait,
2128 due,
2129 )
2130 }
2131 }
2132 });
2133 let outcome = integrated_shared_maintenance_result(config, shared_target, lock_wait, result)?;
2134 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceFinished {
2135 outcome: outcome.clone(),
2136 });
2137 Ok(outcome)
2138}
2139
2140fn integrated_shared_maintenance_result(
2141 config: SharedIncrementalTargetMaintenanceConfig,
2142 shared_target: &Path,
2143 lock_wait: Duration,
2144 result: Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError>,
2145) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2146 match result {
2147 Ok(outcome) => Ok(outcome),
2148 Err(error)
2149 if config.failure_mode == SharedIncrementalTargetMaintenanceFailureMode::BestEffort =>
2150 {
2151 Ok(SharedIncrementalTargetMaintenanceOutcome::Failed {
2152 target_dir: shared_target.to_owned(),
2153 lock_wait,
2154 message: error.to_string(),
2155 })
2156 }
2157 Err(error) => Err(error),
2158 }
2159}
2160
2161fn resolve_inputs_with_progress(
2162 spec: &WasmBuildSpec,
2163 progress: &mut ProgressReporter<'_>,
2164) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2165 let resolved = build_fingerprint_with_progress(spec, progress)?;
2166 progress.emit(WasmBuildProgressEvent::InputsResolved {
2167 fingerprint: resolved.fingerprint,
2168 input_digest: resolved.input_digest,
2169 elapsed: resolved.timings.total,
2170 });
2171 Ok(resolved)
2172}
2173
2174fn resolve_initial_inputs(
2175 spec: &WasmBuildSpec,
2176 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_>, usize)>,
2177 progress: &mut ProgressReporter<'_>,
2178) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2179 let resolved = if let Some((resolver, index)) = batch_resolution {
2180 resolver.resolve(index, progress)?
2181 } else {
2182 build_fingerprint_with_progress(spec, progress)?
2183 };
2184 progress.emit(WasmBuildProgressEvent::InputsResolved {
2185 fingerprint: resolved.fingerprint,
2186 input_digest: resolved.input_digest,
2187 elapsed: resolved.timings.total,
2188 });
2189 Ok(resolved)
2190}
2191
2192fn emit_finished_progress(outcome: &WasmBuildOutcome, progress: &mut ProgressReporter<'_>) {
2193 let state = if outcome.is_reused() {
2194 progress.emit(WasmBuildProgressEvent::CacheHit {
2195 fingerprint: outcome.record().fingerprint,
2196 });
2197 WasmBuildProgressOutcome::Reused
2198 } else {
2199 WasmBuildProgressOutcome::Built
2200 };
2201 progress.emit(WasmBuildProgressEvent::Finished {
2202 outcome: state,
2203 fingerprint: outcome.record().fingerprint,
2204 elapsed: outcome.record().timings.total,
2205 });
2206}
2207
2208#[derive(Clone, Debug, Default)]
2209struct SharedIncrementalAcquisitionContext {
2210 lock_wait: Option<Duration>,
2211 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
2212}
2213
2214fn try_reuse_wasm_artifacts(
2215 spec: &WasmBuildSpec,
2216 resolved: &ResolvedCargoBuildInputs,
2217 lock_wait: Duration,
2218 shared_incremental: &SharedIncrementalAcquisitionContext,
2219 total_started: Instant,
2220 progress: &mut ProgressReporter<'_>,
2221) -> Result<Option<WasmBuildOutcome>, WasmBuildError> {
2222 let fingerprint = resolved.fingerprint;
2223 let artifacts = expected_artifacts(spec, &spec.target_dir);
2224 let cache_entry = cache_entry_directory(spec, fingerprint);
2225 let artifacts_match = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2226 artifact_set_matches(&artifacts, fingerprint)
2227 });
2228 if artifacts_match {
2229 progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2230 ensure_exact_cache_entry(spec, &artifacts, &cache_entry, fingerprint)
2231 })?;
2232 return Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
2233 spec,
2234 BuildRecordInput {
2235 fingerprint,
2236 input_digest: resolved.input_digest,
2237 artifacts,
2238 lock_wait,
2239 shared_incremental: shared_incremental.clone(),
2240 input_resolution: resolved.timings,
2241 cargo_build: None,
2242 active_entry: &cache_entry,
2243 },
2244 total_started,
2245 progress,
2246 ))));
2247 }
2248
2249 let cached_artifacts = expected_artifacts(spec, &cache_entry);
2250 let cached_artifacts_match = progress
2251 .run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2252 artifact_set_matches(&cached_artifacts, fingerprint)
2253 });
2254 if !cached_artifacts_match {
2255 return Ok(None);
2256 }
2257 progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2258 materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
2259 record_cache_entry_use(&cache_entry)
2260 })?;
2261 Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
2262 spec,
2263 BuildRecordInput {
2264 fingerprint,
2265 input_digest: resolved.input_digest,
2266 artifacts,
2267 lock_wait,
2268 shared_incremental: shared_incremental.clone(),
2269 input_resolution: resolved.timings,
2270 cargo_build: None,
2271 active_entry: &cache_entry,
2272 },
2273 total_started,
2274 progress,
2275 ))))
2276}
2277
2278fn ensure_exact_cache_entry(
2279 spec: &WasmBuildSpec,
2280 artifacts: &[PathBuf],
2281 cache_entry: &Path,
2282 fingerprint: InputDigest,
2283) -> Result<(), WasmBuildError> {
2284 let cached_artifacts = expected_artifacts(spec, cache_entry);
2285 if artifact_set_matches(&cached_artifacts, fingerprint) {
2286 return record_cache_entry_use(cache_entry);
2287 }
2288 remove_directory_if_present(cache_entry)?;
2289 create_dir_all(
2290 cache_entry,
2291 "create content-addressed Cargo target directory",
2292 )?;
2293 let incomplete = IncompleteBuildDirectory::new(cache_entry.to_owned());
2294 let result = (|| {
2295 copy_wasm_artifacts(artifacts, &cached_artifacts)?;
2296 publish_artifact_stamps(&cached_artifacts, fingerprint)?;
2297 record_cache_entry_use(cache_entry)
2298 })();
2299 match result {
2300 Ok(()) => {
2301 incomplete.preserve();
2302 Ok(())
2303 }
2304 Err(build_error) => {
2305 let path = incomplete.path.clone();
2306 match incomplete.cleanup() {
2307 Ok(()) => Err(build_error),
2308 Err(source) => Err(WasmBuildError::FailedBuildCleanup {
2309 build_error: Box::new(build_error),
2310 path,
2311 source,
2312 }),
2313 }
2314 }
2315 }
2316}
2317
2318fn build_wasm_cache_miss(
2319 spec: &WasmBuildSpec,
2320 resolved: ResolvedCargoBuildInputs,
2321 lock_wait: Duration,
2322 shared_incremental: SharedIncrementalAcquisitionContext,
2323 cargo_target_dir: PathBuf,
2324 total_started: Instant,
2325 progress: &mut ProgressReporter<'_>,
2326) -> Result<WasmBuildOutcome, WasmBuildError> {
2327 let fingerprint = resolved.fingerprint;
2328 let mut input_resolution = resolved.timings;
2329 let artifacts = expected_artifacts(spec, &spec.target_dir);
2330 let cache_entry = cache_entry_directory(spec, fingerprint);
2331 remove_directory_if_present(&cache_entry)?;
2332 create_dir_all(
2333 &cache_entry,
2334 "create content-addressed Cargo target directory",
2335 )?;
2336 let incomplete_directory = IncompleteBuildDirectory::new(cache_entry.clone());
2337 let build_result = (|| {
2338 if matches!(
2339 spec.cache_mode,
2340 WasmBuildCacheMode::SharedIncremental { .. }
2341 ) {
2342 record_cache_entry_use(&cargo_target_dir)?;
2343 }
2344 let build_started = Instant::now();
2345 run_cargo_build(spec, &cargo_target_dir, progress)?;
2346 let cargo_build = build_started.elapsed();
2347 let built_artifacts = expected_artifacts(spec, &cargo_target_dir);
2348 let missing = missing_artifacts(&built_artifacts);
2349 if !missing.is_empty() {
2350 return Err(WasmBuildError::MissingArtifacts { paths: missing });
2351 }
2352
2353 let verified = resolve_inputs_with_progress(spec, progress)?;
2354 input_resolution.include(verified.timings);
2355 if resolved.validation_digest != verified.validation_digest {
2356 return Err(WasmBuildError::InputsChangedDuringBuild {
2357 before: resolved.validation_digest,
2358 after: verified.validation_digest,
2359 });
2360 }
2361 if fingerprint != verified.fingerprint {
2362 return Err(WasmBuildError::InputsChangedDuringBuild {
2363 before: fingerprint,
2364 after: verified.fingerprint,
2365 });
2366 }
2367
2368 let cached_artifacts = expected_artifacts(spec, &cache_entry);
2369 progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2370 if cargo_target_dir != cache_entry {
2371 copy_wasm_artifacts(&built_artifacts, &cached_artifacts)?;
2372 }
2373 publish_artifact_stamps(&cached_artifacts, fingerprint)?;
2374 materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
2375 record_cache_entry_use(&cache_entry)
2376 })?;
2377
2378 Ok(WasmBuildOutcome::Built(complete_build_record(
2379 spec,
2380 BuildRecordInput {
2381 fingerprint,
2382 input_digest: resolved.input_digest,
2383 artifacts,
2384 lock_wait,
2385 shared_incremental,
2386 input_resolution,
2387 cargo_build: Some(cargo_build),
2388 active_entry: &cache_entry,
2389 },
2390 total_started,
2391 progress,
2392 )))
2393 })();
2394 finish_fingerprint_build(build_result, incomplete_directory)
2395}
2396
2397pub fn prune_wasm_build_cache(
2405 target_dir: &Path,
2406 policy: ArtifactCachePrunePolicy,
2407) -> Result<ArtifactCachePruneReport, WasmBuildError> {
2408 let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
2409 ensure_cache_directory_tag(target_dir)?;
2410
2411 prune_wasm_build_cache_locked(target_dir, policy, None)
2412}
2413
2414struct BuildRecordInput<'a> {
2415 fingerprint: InputDigest,
2416 input_digest: InputDigest,
2417 artifacts: Vec<PathBuf>,
2418 lock_wait: Duration,
2419 shared_incremental: SharedIncrementalAcquisitionContext,
2420 input_resolution: WasmInputResolutionTimings,
2421 cargo_build: Option<Duration>,
2422 active_entry: &'a Path,
2423}
2424
2425fn complete_build_record(
2426 spec: &WasmBuildSpec,
2427 input: BuildRecordInput<'_>,
2428 total_started: Instant,
2429 progress: &mut ProgressReporter<'_>,
2430) -> WasmBuildRecord {
2431 let (maintenance, cache_maintenance) = spec.prune_policy.map_or((None, None), |policy| {
2432 progress.run_phase(WasmBuildProgressPhase::ExactCacheMaintenance, || {
2433 let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
2434 let identity = policy.maintenance_identity();
2435 perform_scheduled_cache_maintenance(&cache_root, spec.prune_interval, &identity, || {
2436 prune_wasm_build_cache_locked(&spec.target_dir, policy, Some(input.active_entry))
2437 .map_err(|error| error.to_string())
2438 })
2439 })
2440 });
2441 WasmBuildRecord {
2442 fingerprint: input.fingerprint,
2443 input_digest: input.input_digest,
2444 exact_cache_path: input.active_entry.to_owned(),
2445 artifacts: input.artifacts,
2446 timings: WasmBuildTimings {
2447 lock_wait: input.lock_wait,
2448 shared_incremental_lock_wait: input.shared_incremental.lock_wait,
2449 input_resolution: input.input_resolution,
2450 cargo_build: input.cargo_build,
2451 cache_maintenance,
2452 total: total_started.elapsed(),
2453 },
2454 maintenance,
2455 shared_incremental_maintenance: input.shared_incremental.maintenance,
2456 }
2457}
2458
2459fn prune_wasm_build_cache_locked(
2460 target_dir: &Path,
2461 policy: ArtifactCachePrunePolicy,
2462 protected_entry: Option<&Path>,
2463) -> Result<ArtifactCachePruneReport, WasmBuildError> {
2464 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
2465 prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
2466 .map_err(wasm_cache_fs_error)
2467}
2468
2469struct IncompleteBuildDirectory {
2470 path: PathBuf,
2471 armed: bool,
2472}
2473
2474impl IncompleteBuildDirectory {
2475 const fn new(path: PathBuf) -> Self {
2476 Self { path, armed: true }
2477 }
2478
2479 fn preserve(mut self) {
2480 self.armed = false;
2481 }
2482
2483 fn cleanup(mut self) -> io::Result<()> {
2484 let result = remove_path_if_present(&self.path);
2485 if result.is_ok() {
2486 self.armed = false;
2487 }
2488 result
2489 }
2490}
2491
2492impl Drop for IncompleteBuildDirectory {
2493 fn drop(&mut self) {
2494 if self.armed {
2495 let _ = remove_path_if_present(&self.path);
2496 }
2497 }
2498}
2499
2500fn finish_fingerprint_build(
2501 result: Result<WasmBuildOutcome, WasmBuildError>,
2502 incomplete_directory: IncompleteBuildDirectory,
2503) -> Result<WasmBuildOutcome, WasmBuildError> {
2504 match result {
2505 Ok(outcome) => {
2506 incomplete_directory.preserve();
2507 Ok(outcome)
2508 }
2509 Err(build_error) => {
2510 let path = incomplete_directory.path.clone();
2511 match incomplete_directory.cleanup() {
2512 Ok(()) => Err(build_error),
2513 Err(source) => Err(WasmBuildError::FailedBuildCleanup {
2514 build_error: Box::new(build_error),
2515 path,
2516 source,
2517 }),
2518 }
2519 }
2520 }
2521}
2522
2523fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
2524 create_dir_all(target_dir, "create Cargo target directory")?;
2525 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
2526 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)
2527}
2528
2529fn lock_wasm_build_cache_with_progress(
2530 target_dir: &Path,
2531 progress: &mut ProgressReporter<'_>,
2532) -> Result<(File, Duration), WasmBuildError> {
2533 create_dir_all(target_dir, "create Cargo target directory")?;
2534 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
2535 lock_cache_file_with_progress(&lock_path, WasmBuildProgressPhase::ExactCacheLock, progress)
2536}
2537
2538fn lock_shared_incremental_target(
2539 spec: &WasmBuildSpec,
2540) -> Result<(File, Duration, PathBuf), WasmBuildError> {
2541 lock_shared_incremental_target_internal(spec, None)
2542}
2543
2544fn lock_shared_incremental_target_with_progress(
2545 spec: &WasmBuildSpec,
2546 progress: &mut ProgressReporter<'_>,
2547) -> Result<(File, Duration, PathBuf), WasmBuildError> {
2548 lock_shared_incremental_target_internal(spec, Some(progress))
2549}
2550
2551fn lock_shared_incremental_target_internal(
2552 spec: &WasmBuildSpec,
2553 progress: Option<&mut ProgressReporter<'_>>,
2554) -> Result<(File, Duration, PathBuf), WasmBuildError> {
2555 let target_dir =
2556 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
2557 message: "shared incremental target is not configured".to_owned(),
2558 })?;
2559 create_dir_all(
2560 &target_dir,
2561 "create shared incremental Cargo target directory",
2562 )?;
2563 ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
2564 let canonical = target_dir
2565 .canonicalize()
2566 .map_err(|source| WasmBuildError::Io {
2567 operation: "resolve shared incremental Cargo target directory",
2568 path: target_dir.clone(),
2569 source,
2570 })?;
2571 let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
2572 let (lock, wait) = if let Some(progress) = progress {
2573 lock_cache_file_with_progress(
2574 &lock_path,
2575 WasmBuildProgressPhase::SharedTargetLock,
2576 progress,
2577 )?
2578 } else {
2579 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)?
2580 };
2581 Ok((lock, wait, canonical))
2582}
2583
2584fn lock_cache_file_with_progress(
2585 lock_path: &Path,
2586 phase: WasmBuildProgressPhase,
2587 progress: &mut ProgressReporter<'_>,
2588) -> Result<(File, Duration), WasmBuildError> {
2589 if !progress.is_observed() || progress.config.heartbeat_interval.is_none() {
2590 return lock_cache_file(lock_path).map_err(wasm_cache_fs_error);
2591 }
2592 let heartbeat_interval = progress
2593 .config
2594 .heartbeat_interval
2595 .expect("observed cache lock must have a heartbeat interval");
2596 lock_cache_file_with_wait_observer(lock_path, heartbeat_interval, |elapsed| {
2597 progress.emit_heartbeat_if_due(phase, elapsed);
2598 })
2599 .map_err(wasm_cache_fs_error)
2600}
2601
2602fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
2603 ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
2604}
2605
2606fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
2607 record_entry_use(path).map_err(wasm_cache_fs_error)
2608}
2609
2610fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
2611 WasmBuildError::Io {
2612 operation: error.operation,
2613 path: error.path,
2614 source: error.source,
2615 }
2616}
2617
2618fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
2619 if spec.packages.is_empty() {
2620 return Err(WasmBuildError::InvalidSpec {
2621 message: "at least one Cargo package is required".to_owned(),
2622 });
2623 }
2624 if spec.profile_target_dir.is_empty() {
2625 return Err(WasmBuildError::InvalidSpec {
2626 message: "Cargo profile target directory must not be empty".to_owned(),
2627 });
2628 }
2629 if spec.target.is_empty() {
2630 return Err(WasmBuildError::InvalidSpec {
2631 message: "Cargo compilation target must not be empty".to_owned(),
2632 });
2633 }
2634 if matches!(
2635 &spec.cache_mode,
2636 WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
2637 ) {
2638 return Err(WasmBuildError::InvalidSpec {
2639 message: "shared incremental Cargo target directory must not be empty".to_owned(),
2640 });
2641 }
2642 if spec.shared_incremental_maintenance_config.is_some()
2643 && !matches!(
2644 spec.cache_mode,
2645 WasmBuildCacheMode::SharedIncremental { .. }
2646 )
2647 {
2648 return Err(WasmBuildError::InvalidSpec {
2649 message:
2650 "scheduled shared-target maintenance requires a shared incremental Cargo target"
2651 .to_owned(),
2652 });
2653 }
2654 Ok(())
2655}
2656
2657fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2658 build_fingerprint_with_progress(spec, &mut ProgressReporter::silent())
2659}
2660
2661fn build_fingerprint_with_progress(
2662 spec: &WasmBuildSpec,
2663 progress: &mut ProgressReporter<'_>,
2664) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2665 let total_started = Instant::now();
2666 let tool_started = Instant::now();
2667 let cargo_identity = progress.run_phase(WasmBuildProgressPhase::CargoIdentity, || {
2668 command_identity(
2669 spec,
2670 WasmBuildPhase::CargoIdentity,
2671 &spec.cargo_program,
2672 &["--version", "--verbose"],
2673 )
2674 })?;
2675 let rustc_program = spec
2676 .extra_env
2677 .get(OsStr::new("RUSTC"))
2678 .unwrap_or(&spec.rustc_program);
2679 let rustc_identity = progress.run_phase(WasmBuildProgressPhase::RustcIdentity, || {
2680 command_identity(spec, WasmBuildPhase::RustcIdentity, rustc_program, &["-vV"])
2681 })?;
2682 let tool_identity = tool_started.elapsed();
2683
2684 let metadata_started = Instant::now();
2685 let metadata = progress.run_phase(WasmBuildProgressPhase::CargoMetadata, || {
2686 cargo_metadata(spec)
2687 })?;
2688 let cargo_metadata = metadata_started.elapsed();
2689
2690 let discovery_started = Instant::now();
2691 let (inputs, exclusions) =
2692 progress.run_phase(WasmBuildProgressPhase::InputDiscovery, || {
2693 let inputs = resolve_local_inputs(spec, &metadata)?;
2694 validate_shared_incremental_target_boundary(spec, &inputs.validation_inputs)?;
2695 let exclusions = source_exclusions(spec, &inputs.validation_inputs);
2696 Ok::<_, WasmBuildError>((inputs, exclusions))
2697 })?;
2698 let input_discovery = discovery_started.elapsed();
2699
2700 let hashing_started = Instant::now();
2701 let (input_digest, validation_digest) =
2702 progress.run_phase(WasmBuildProgressPhase::ContentHashing, || {
2703 let mut cache = LabeledPathDigestCache::default();
2704 digest_resolved_local_inputs(
2705 &inputs,
2706 &exclusions,
2707 &mut cache,
2708 &spec.workspace_root,
2709 "hash Wasm build inputs",
2710 "hash semantic Wasm build inputs",
2711 )
2712 })?;
2713 let content_hashing = hashing_started.elapsed();
2714
2715 let fingerprint =
2716 finish_build_fingerprint(spec, &cargo_identity, &rustc_identity, input_digest);
2717 Ok(ResolvedCargoBuildInputs {
2718 fingerprint,
2719 input_digest,
2720 validation_digest,
2721 inputs: inputs
2722 .validation_inputs
2723 .into_iter()
2724 .map(|(label, path)| CargoBuildInput { label, path })
2725 .collect(),
2726 exclusions,
2727 timings: WasmInputResolutionTimings {
2728 tool_identity,
2729 cargo_metadata,
2730 input_discovery,
2731 content_hashing,
2732 total: total_started.elapsed(),
2733 },
2734 })
2735}
2736
2737fn finish_build_fingerprint(
2738 spec: &WasmBuildSpec,
2739 cargo_identity: &[u8],
2740 rustc_identity: &[u8],
2741 input_digest: InputDigest,
2742) -> InputDigest {
2743 let mut hasher = InputHasher::new(CACHE_FORMAT_VERSION);
2744 let mut packages = spec.packages.clone();
2745 packages.sort();
2746 packages.dedup();
2747 for package in packages {
2748 hasher.field("package", package.as_bytes());
2749 }
2750 hasher.field("target", spec.target.as_bytes());
2751 hasher.field("profile-target-dir", spec.profile_target_dir.as_bytes());
2752 for argument in &spec.cargo_profile_args {
2753 hasher.field("cargo-argument", &os_bytes(argument));
2754 }
2755 for (key, value) in effective_environment(spec) {
2756 hasher.field("environment-key", &os_bytes(&key));
2757 if let Some(value) = value {
2758 hasher.field("environment-value", &os_bytes(&value));
2759 } else {
2760 hasher.field("environment-unset", b"");
2761 }
2762 }
2763 hasher.field("cargo-identity", cargo_identity);
2764 hasher.field("rustc-identity", rustc_identity);
2765 hasher.field("source-input-digest", input_digest.as_bytes());
2766 hasher.finish()
2767}
2768
2769fn command_identity(
2770 spec: &WasmBuildSpec,
2771 phase: WasmBuildPhase,
2772 program: &OsStr,
2773 arguments: &[&str],
2774) -> Result<Vec<u8>, WasmBuildError> {
2775 let mut command = Command::new(program);
2776 command.current_dir(&spec.workspace_root).args(arguments);
2777 apply_command_environment(&mut command, spec);
2778 let output = command
2779 .output()
2780 .map_err(|source| WasmBuildError::CommandSpawn {
2781 phase,
2782 program: program.to_owned(),
2783 source,
2784 })?;
2785 ensure_command_success(phase, output).map(|output| {
2786 let mut identity = output.stdout;
2787 identity.extend_from_slice(&output.stderr);
2788 identity
2789 })
2790}
2791
2792fn cargo_metadata(spec: &WasmBuildSpec) -> Result<Value, WasmBuildError> {
2793 let mut command = Command::new(&spec.cargo_program);
2794 command
2795 .current_dir(&spec.workspace_root)
2796 .args(["metadata", "--format-version", "1"]);
2797 for argument in metadata_arguments(&spec.cargo_profile_args) {
2798 command.arg(argument);
2799 }
2800 apply_command_environment(&mut command, spec);
2801 let output = command
2802 .output()
2803 .map_err(|source| WasmBuildError::CommandSpawn {
2804 phase: WasmBuildPhase::CargoMetadata,
2805 program: spec.cargo_program.clone(),
2806 source,
2807 })?;
2808 let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
2809 serde_json::from_slice(&output.stdout).map_err(|error| WasmBuildError::InvalidMetadata {
2810 message: format!("Cargo metadata was not valid JSON: {error}"),
2811 })
2812}
2813
2814fn metadata_arguments(arguments: &[OsString]) -> Vec<OsString> {
2815 let mut selected = Vec::new();
2816 let mut arguments = arguments.iter();
2817 while let Some(argument) = arguments.next() {
2818 let argument_text = argument.to_string_lossy();
2819 match argument_text.as_ref() {
2820 "--all-features" | "--no-default-features" | "--locked" | "--offline" | "--frozen" => {
2821 selected.push(argument.clone());
2822 }
2823 "--features" | "-F" | "--filter-platform" => {
2824 selected.push(argument.clone());
2825 if let Some(value) = arguments.next() {
2826 selected.push(value.clone());
2827 }
2828 }
2829 _ if argument_text.starts_with("--features=")
2830 || argument_text.starts_with("--filter-platform=") =>
2831 {
2832 selected.push(argument.clone());
2833 }
2834 _ => {}
2835 }
2836 }
2837 selected
2838}
2839
2840#[derive(Clone)]
2841struct MetadataPackage {
2842 id: String,
2843 name: String,
2844 version: String,
2845 manifest_path: PathBuf,
2846 is_local: bool,
2847 source: Option<String>,
2848 semantic_fields: Vec<(&'static str, Option<String>)>,
2849}
2850
2851const SEMANTIC_PACKAGE_FIELDS: &[&str] = &[
2852 "authors",
2853 "default_run",
2854 "description",
2855 "documentation",
2856 "edition",
2857 "homepage",
2858 "license",
2859 "license_file",
2860 "links",
2861 "metadata",
2862 "name",
2863 "readme",
2864 "repository",
2865 "rust_version",
2866 "version",
2867];
2868
2869struct LockedPackageIdentity {
2870 name: String,
2871 version: String,
2872 source: String,
2873 checksum: Option<String>,
2874}
2875
2876fn resolve_local_inputs(
2877 spec: &WasmBuildSpec,
2878 metadata: &Value,
2879) -> Result<ResolvedLocalInputs, WasmBuildError> {
2880 let packages = metadata_packages(metadata)?;
2881 let mut selected_ids = selected_package_ids(spec, metadata, &packages)?;
2882 let dependencies = metadata_dependencies(metadata)?;
2883 let mut closure = BTreeSet::new();
2884 while let Some(id) = selected_ids.pop_front() {
2885 if !closure.insert(id.clone()) {
2886 continue;
2887 }
2888 if let Some(deps) = dependencies.get(&id) {
2889 selected_ids.extend(deps.iter().cloned());
2890 }
2891 }
2892
2893 let workspace_root = metadata
2894 .get("workspace_root")
2895 .and_then(Value::as_str)
2896 .map_or_else(|| spec.workspace_root.clone(), PathBuf::from);
2897 let projection = semantic_workspace_projection(metadata, &packages, &closure, &workspace_root)?;
2898 let mut validation_inputs = workspace_configuration_inputs(spec, &workspace_root)?;
2899 append_package_inputs(&mut validation_inputs, &packages, closure, &workspace_root)?;
2900 append_additional_inputs(&mut validation_inputs, spec, &workspace_root);
2901 let fingerprint = projection.map_or(LocalInputFingerprint::Conservative, |workspace| {
2902 LocalInputFingerprint::Projected {
2903 inputs: validation_inputs
2904 .iter()
2905 .filter(|(label, _)| !is_broad_workspace_input(label))
2906 .cloned()
2907 .collect(),
2908 workspace,
2909 }
2910 });
2911 Ok(ResolvedLocalInputs {
2912 validation_inputs,
2913 fingerprint,
2914 })
2915}
2916
2917fn metadata_packages(metadata: &Value) -> Result<HashMap<String, MetadataPackage>, WasmBuildError> {
2918 let packages_value = metadata
2919 .get("packages")
2920 .and_then(Value::as_array)
2921 .ok_or_else(|| invalid_metadata("Cargo metadata has no package array"))?;
2922 let mut packages = HashMap::new();
2923 for value in packages_value {
2924 let source = optional_string(value, "source")?;
2925 let package = MetadataPackage {
2926 id: required_string(value, "id")?,
2927 name: required_string(value, "name")?,
2928 version: required_string(value, "version")?,
2929 manifest_path: PathBuf::from(required_string(value, "manifest_path")?),
2930 is_local: value.get("source").is_some_and(Value::is_null),
2931 source,
2932 semantic_fields: SEMANTIC_PACKAGE_FIELDS
2933 .iter()
2934 .map(|field| (*field, value.get(*field).map(Value::to_string)))
2935 .collect(),
2936 };
2937 packages.insert(package.id.clone(), package);
2938 }
2939 Ok(packages)
2940}
2941
2942fn selected_package_ids(
2943 spec: &WasmBuildSpec,
2944 metadata: &Value,
2945 packages: &HashMap<String, MetadataPackage>,
2946) -> Result<VecDeque<String>, WasmBuildError> {
2947 let workspace_members = metadata
2948 .get("workspace_members")
2949 .and_then(Value::as_array)
2950 .ok_or_else(|| invalid_metadata("Cargo metadata has no workspace member array"))?
2951 .iter()
2952 .filter_map(Value::as_str)
2953 .collect::<HashSet<_>>();
2954 let mut selected_ids = VecDeque::new();
2955 for requested in &spec.packages {
2956 let matches = packages
2957 .values()
2958 .filter(|package| {
2959 package.name == *requested && workspace_members.contains(package.id.as_str())
2960 })
2961 .map(|package| package.id.clone())
2962 .collect::<Vec<_>>();
2963 match matches.as_slice() {
2964 [id] => selected_ids.push_back(id.clone()),
2965 [] => {
2966 return Err(WasmBuildError::InvalidSpec {
2967 message: format!("Cargo workspace contains no package named `{requested}`"),
2968 });
2969 }
2970 _ => {
2971 return Err(WasmBuildError::InvalidSpec {
2972 message: format!("Cargo workspace package name `{requested}` is ambiguous"),
2973 });
2974 }
2975 }
2976 }
2977 Ok(selected_ids)
2978}
2979
2980fn metadata_dependencies(metadata: &Value) -> Result<HashMap<String, Vec<String>>, WasmBuildError> {
2981 let mut dependencies = HashMap::<String, Vec<String>>::new();
2982 let nodes = metadata
2983 .pointer("/resolve/nodes")
2984 .and_then(Value::as_array)
2985 .ok_or_else(|| invalid_metadata("Cargo metadata has no resolved dependency nodes"))?;
2986 for node in nodes {
2987 let id = required_string(node, "id")?;
2988 let deps = node
2989 .get("deps")
2990 .and_then(Value::as_array)
2991 .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
2992 .iter()
2993 .map(|dependency| required_string(dependency, "pkg"))
2994 .collect::<Result<Vec<_>, _>>()?;
2995 dependencies.insert(id, deps);
2996 }
2997 Ok(dependencies)
2998}
2999
3000fn semantic_workspace_projection(
3001 metadata: &Value,
3002 packages: &HashMap<String, MetadataPackage>,
3003 closure: &BTreeSet<String>,
3004 workspace_root: &Path,
3005) -> Result<Option<InputDigest>, WasmBuildError> {
3006 let locked_packages = locked_package_identities(workspace_root)?;
3009 let mut identities = HashMap::new();
3010 for id in closure {
3011 let package = packages
3012 .get(id)
3013 .ok_or_else(|| invalid_metadata(&format!("resolved package `{id}` is missing")))?;
3014 let Some(identity) = semantic_package_identity(package, workspace_root, &locked_packages)
3015 else {
3016 return Ok(None);
3017 };
3018 identities.insert(id.as_str(), identity);
3019 }
3020
3021 let nodes = metadata
3022 .pointer("/resolve/nodes")
3023 .and_then(Value::as_array)
3024 .ok_or_else(|| invalid_metadata("Cargo metadata has no resolved dependency nodes"))?;
3025 let nodes_by_id = nodes
3026 .iter()
3027 .map(|node| Ok((required_string(node, "id")?, node)))
3028 .collect::<Result<HashMap<_, _>, WasmBuildError>>()?;
3029 let mut projected_packages = closure
3030 .iter()
3031 .map(|id| {
3032 let package = packages
3033 .get(id)
3034 .expect("selected package closure was validated above");
3035 let identity = identities[id.as_str()];
3036 let node = nodes_by_id.get(id).copied().ok_or_else(|| {
3037 invalid_metadata(&format!("resolved package `{id}` has no dependency node"))
3038 })?;
3039 let projection = semantic_package_projection(package, node, &identities)?;
3040 Ok::<_, WasmBuildError>((identity, projection))
3041 })
3042 .collect::<Result<Vec<_>, _>>()?;
3043 projected_packages.sort_by_key(|(identity, _)| *identity);
3044
3045 let root_manifest = workspace_root.join("Cargo.toml");
3046 let root_contents =
3047 fs::read_to_string(&root_manifest).map_err(|source| WasmBuildError::Io {
3048 operation: "read workspace manifest for semantic projection",
3049 path: root_manifest.clone(),
3050 source,
3051 })?;
3052 let root = toml::from_str::<TomlValue>(&root_contents).map_err(|error| {
3053 invalid_metadata(&format!(
3054 "workspace manifest could not be projected as TOML: {error}"
3055 ))
3056 })?;
3057
3058 let mut hasher = InputHasher::new("wasm-semantic-workspace-projection-v1");
3059 for (identity, projection) in projected_packages {
3060 hasher.field("package-identity", identity.as_bytes());
3061 hasher.field("package-projection", projection.as_bytes());
3062 }
3063 hash_toml_setting(&mut hasher, "cargo-features", root.get("cargo-features"));
3064 hash_toml_setting(&mut hasher, "profile", root.get("profile"));
3065 let workspace = root.get("workspace").and_then(TomlValue::as_table);
3066 hash_toml_setting(
3067 &mut hasher,
3068 "workspace-resolver",
3069 workspace.and_then(|table| table.get("resolver")),
3070 );
3071 hash_toml_setting(
3072 &mut hasher,
3073 "workspace-lints",
3074 workspace.and_then(|table| table.get("lints")),
3075 );
3076 Ok(Some(hasher.finish()))
3077}
3078
3079fn locked_package_identities(
3080 workspace_root: &Path,
3081) -> Result<Vec<LockedPackageIdentity>, WasmBuildError> {
3082 let lockfile = workspace_root.join("Cargo.lock");
3083 let contents = match fs::read_to_string(&lockfile) {
3084 Ok(contents) => contents,
3085 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
3086 Err(source) => {
3087 return Err(WasmBuildError::Io {
3088 operation: "read Cargo lockfile for semantic projection",
3089 path: lockfile,
3090 source,
3091 });
3092 }
3093 };
3094 let lock = toml::from_str::<TomlValue>(&contents).map_err(|error| {
3095 invalid_metadata(&format!(
3096 "Cargo lockfile could not be projected as TOML: {error}"
3097 ))
3098 })?;
3099 let Some(packages) = lock.get("package").and_then(TomlValue::as_array) else {
3100 return Ok(Vec::new());
3101 };
3102 packages
3103 .iter()
3104 .filter_map(|package| {
3105 let Some(table) = package.as_table() else {
3106 return Some(Err(invalid_metadata(
3107 "Cargo lockfile package entry is not a table",
3108 )));
3109 };
3110 let source = table.get("source")?.as_str().map(str::to_owned);
3111 Some(
3112 source
3113 .ok_or_else(|| {
3114 invalid_metadata("Cargo lockfile package source is not a string")
3115 })
3116 .and_then(|source| {
3117 Ok(LockedPackageIdentity {
3118 name: required_toml_string(table, "name", "Cargo lockfile package")?,
3119 version: required_toml_string(
3120 table,
3121 "version",
3122 "Cargo lockfile package",
3123 )?,
3124 source,
3125 checksum: optional_toml_string(
3126 table,
3127 "checksum",
3128 "Cargo lockfile package",
3129 )?,
3130 })
3131 }),
3132 )
3133 })
3134 .collect()
3135}
3136
3137fn required_toml_string(
3138 table: &toml::Table,
3139 field: &str,
3140 context: &str,
3141) -> Result<String, WasmBuildError> {
3142 table
3143 .get(field)
3144 .and_then(TomlValue::as_str)
3145 .map(str::to_owned)
3146 .ok_or_else(|| invalid_metadata(&format!("{context} `{field}` is missing or not a string")))
3147}
3148
3149fn optional_toml_string(
3150 table: &toml::Table,
3151 field: &str,
3152 context: &str,
3153) -> Result<Option<String>, WasmBuildError> {
3154 match table.get(field) {
3155 None => Ok(None),
3156 Some(TomlValue::String(value)) => Ok(Some(value.clone())),
3157 Some(_) => Err(invalid_metadata(&format!(
3158 "{context} `{field}` is not a string"
3159 ))),
3160 }
3161}
3162
3163fn semantic_package_identity(
3164 package: &MetadataPackage,
3165 workspace_root: &Path,
3166 locked_packages: &[LockedPackageIdentity],
3167) -> Option<InputDigest> {
3168 let mut hasher = InputHasher::new("wasm-semantic-package-identity-v1");
3169 hasher.field("name", package.name.as_bytes());
3170 hasher.field("version", package.version.as_bytes());
3171 if package.is_local {
3172 let manifest = package.manifest_path.strip_prefix(workspace_root).ok()?;
3173 let package_root = package.manifest_path.parent()?;
3174 if package_root == workspace_root {
3175 return None;
3176 }
3177 hasher.field("local-manifest", &os_bytes(manifest.as_os_str()));
3178 } else {
3179 let metadata_source = package.source.as_deref()?;
3180 let locked = locked_packages.iter().find(|locked| {
3181 locked.name == package.name
3182 && locked.version == package.version
3183 && locked.source == metadata_source
3184 })?;
3185 match locked.source.as_str() {
3186 source if source.starts_with("registry+") && locked.checksum.is_some() => {}
3187 source if source.starts_with("git+") && source.contains('#') => {}
3188 _ => return None,
3189 }
3190 hasher.field("external-package-id", package.id.as_bytes());
3191 hasher.field("external-source", locked.source.as_bytes());
3192 hasher.field(
3193 "external-checksum",
3194 locked.checksum.as_deref().unwrap_or_default().as_bytes(),
3195 );
3196 }
3197 Some(hasher.finish())
3198}
3199
3200fn semantic_package_projection(
3201 package: &MetadataPackage,
3202 node: &Value,
3203 identities: &HashMap<&str, InputDigest>,
3204) -> Result<InputDigest, WasmBuildError> {
3205 let mut hasher = InputHasher::new("wasm-semantic-package-projection-v1");
3209 for (field, value) in &package.semantic_fields {
3210 hasher.field("package-field-name", field.as_bytes());
3211 match value {
3212 Some(value) => hasher.field("package-field-value", value.as_bytes()),
3213 None => hasher.field("package-field-missing", b""),
3214 }
3215 }
3216
3217 let mut features = node
3218 .get("features")
3219 .and_then(Value::as_array)
3220 .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no features array"))?
3221 .iter()
3222 .map(|feature| {
3223 feature.as_str().map(str::to_owned).ok_or_else(|| {
3224 invalid_metadata("Cargo metadata dependency feature is not a string")
3225 })
3226 })
3227 .collect::<Result<Vec<_>, _>>()?;
3228 features.sort();
3229 for feature in features {
3230 hasher.field("enabled-feature", feature.as_bytes());
3231 }
3232
3233 let mut dependencies = node
3234 .get("deps")
3235 .and_then(Value::as_array)
3236 .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
3237 .iter()
3238 .map(|dependency| {
3239 let name = required_string(dependency, "name")?;
3240 let package_id = required_string(dependency, "pkg")?;
3241 let identity = identities
3242 .get(package_id.as_str())
3243 .copied()
3244 .ok_or_else(|| {
3245 invalid_metadata(&format!(
3246 "dependency `{package_id}` is outside the selected package closure"
3247 ))
3248 })?;
3249 let kinds = dependency
3250 .get("dep_kinds")
3251 .ok_or_else(|| invalid_metadata("Cargo metadata dependency has no kind array"))?
3252 .to_string();
3253 Ok::<_, WasmBuildError>((name, identity, kinds))
3254 })
3255 .collect::<Result<Vec<_>, _>>()?;
3256 dependencies.sort();
3257 for (name, identity, kinds) in dependencies {
3258 hasher.field("dependency-name", name.as_bytes());
3259 hasher.field("dependency-identity", identity.as_bytes());
3260 hasher.field("dependency-kinds", kinds.as_bytes());
3261 }
3262 Ok(hasher.finish())
3263}
3264
3265fn hash_toml_setting(hasher: &mut InputHasher, label: &str, value: Option<&TomlValue>) {
3266 hasher.field("workspace-setting-name", label.as_bytes());
3267 match value {
3268 Some(value) => hasher.field("workspace-setting-value", value.to_string().as_bytes()),
3269 None => hasher.field("workspace-setting-missing", b""),
3270 }
3271}
3272
3273fn is_broad_workspace_input(label: &Path) -> bool {
3274 label == Path::new("workspace/Cargo.toml") || label == Path::new("workspace/Cargo.lock")
3275}
3276
3277fn digest_resolved_local_inputs(
3278 inputs: &ResolvedLocalInputs,
3279 exclusions: &[PathBuf],
3280 cache: &mut LabeledPathDigestCache,
3281 error_path: &Path,
3282 validation_operation: &'static str,
3283 semantic_operation: &'static str,
3284) -> Result<(InputDigest, InputDigest), WasmBuildError> {
3285 let validation_digest = digest_labeled_paths_composable(
3286 "wasm-source-inputs-v1",
3287 &inputs.validation_inputs,
3288 exclusions,
3289 cache,
3290 )
3291 .map_err(|source| WasmBuildError::Io {
3292 operation: validation_operation,
3293 path: error_path.to_owned(),
3294 source,
3295 })?;
3296 let input_digest = semantic_input_digest(inputs, validation_digest, exclusions, cache)
3297 .map_err(|source| WasmBuildError::Io {
3298 operation: semantic_operation,
3299 path: error_path.to_owned(),
3300 source,
3301 })?;
3302 Ok((input_digest, validation_digest))
3303}
3304
3305fn semantic_input_digest(
3306 inputs: &ResolvedLocalInputs,
3307 validation_digest: InputDigest,
3308 exclusions: &[PathBuf],
3309 cache: &mut LabeledPathDigestCache,
3310) -> io::Result<InputDigest> {
3311 let LocalInputFingerprint::Projected {
3312 inputs: fingerprint_inputs,
3313 workspace,
3314 } = &inputs.fingerprint
3315 else {
3316 return Ok(validation_digest);
3317 };
3318 let path_digest = digest_labeled_paths_composable(
3319 "wasm-source-inputs-v1",
3320 fingerprint_inputs,
3321 exclusions,
3322 cache,
3323 )?;
3324 let mut hasher = InputHasher::new("wasm-semantic-source-inputs-v1");
3325 hasher.field("path-input-digest", path_digest.as_bytes());
3326 hasher.field("workspace-projection", workspace.as_bytes());
3327 Ok(hasher.finish())
3328}
3329
3330fn workspace_configuration_inputs(
3331 spec: &WasmBuildSpec,
3332 workspace_root: &Path,
3333) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
3334 let mut inputs = Vec::new();
3335 add_if_present(
3336 &mut inputs,
3337 "workspace/Cargo.toml",
3338 workspace_root.join("Cargo.toml"),
3339 );
3340 add_if_present(
3341 &mut inputs,
3342 "workspace/Cargo.lock",
3343 workspace_root.join("Cargo.lock"),
3344 );
3345 add_if_present(
3346 &mut inputs,
3347 "workspace/rust-toolchain.toml",
3348 workspace_root.join("rust-toolchain.toml"),
3349 );
3350 add_if_present(
3351 &mut inputs,
3352 "workspace/rust-toolchain",
3353 workspace_root.join("rust-toolchain"),
3354 );
3355 append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
3356 Ok(inputs)
3357}
3358
3359fn append_cargo_configuration_inputs(
3360 inputs: &mut Vec<(PathBuf, PathBuf)>,
3361 spec: &WasmBuildSpec,
3362 workspace_root: &Path,
3363) -> Result<(), WasmBuildError> {
3364 let invocation_root =
3365 spec.workspace_root
3366 .canonicalize()
3367 .map_err(|source| WasmBuildError::Io {
3368 operation: "resolve Cargo invocation directory",
3369 path: spec.workspace_root.clone(),
3370 source,
3371 })?;
3372 let canonical_workspace =
3373 workspace_root
3374 .canonicalize()
3375 .map_err(|source| WasmBuildError::Io {
3376 operation: "resolve Cargo workspace directory",
3377 path: workspace_root.to_owned(),
3378 source,
3379 })?;
3380
3381 let mut roots = invocation_root
3382 .ancestors()
3383 .filter_map(|directory| effective_cargo_config(&directory.join(".cargo")))
3384 .collect::<Vec<_>>();
3385 if let Some(cargo_home) = effective_cargo_home(spec, &invocation_root)
3386 && let Some(config) = effective_cargo_config(&cargo_home)
3387 {
3388 roots.push(config);
3389 }
3390
3391 let mut visited = BTreeSet::new();
3392 for config in roots {
3393 append_cargo_configuration_tree(
3394 inputs,
3395 &config,
3396 &canonical_workspace,
3397 &mut visited,
3398 false,
3399 )?;
3400 }
3401 Ok(())
3402}
3403
3404fn effective_cargo_config(directory: &Path) -> Option<PathBuf> {
3405 let extensionless = directory.join("config");
3406 if extensionless.exists() {
3407 return Some(extensionless);
3408 }
3409 let toml = directory.join("config.toml");
3410 toml.exists().then_some(toml)
3411}
3412
3413fn effective_cargo_home(spec: &WasmBuildSpec, invocation_root: &Path) -> Option<PathBuf> {
3414 if let Some(cargo_home) = command_environment_value(spec, "CARGO_HOME") {
3415 let cargo_home = PathBuf::from(cargo_home);
3416 return Some(if cargo_home.is_absolute() {
3417 cargo_home
3418 } else {
3419 invocation_root.join(cargo_home)
3420 });
3421 }
3422
3423 default_home_directory(spec).map(|home| {
3424 let home = if home.is_absolute() {
3425 home
3426 } else {
3427 invocation_root.join(home)
3428 };
3429 home.join(".cargo")
3430 })
3431}
3432
3433#[cfg(windows)]
3434fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
3435 command_environment_value(spec, "USERPROFILE")
3436 .or_else(|| command_environment_value(spec, "HOME"))
3437 .map(PathBuf::from)
3438}
3439
3440#[cfg(not(windows))]
3441fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
3442 command_environment_value(spec, "HOME").map(PathBuf::from)
3443}
3444
3445fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
3446 spec.extra_env
3447 .get(OsStr::new(name))
3448 .cloned()
3449 .or_else(|| std::env::var_os(name))
3450}
3451
3452fn append_cargo_configuration_tree(
3453 inputs: &mut Vec<(PathBuf, PathBuf)>,
3454 config: &Path,
3455 workspace_root: &Path,
3456 visited: &mut BTreeSet<PathBuf>,
3457 optional: bool,
3458) -> Result<(), WasmBuildError> {
3459 let canonical = match config.canonicalize() {
3460 Ok(canonical) => canonical,
3461 Err(error) if optional && error.kind() == io::ErrorKind::NotFound => return Ok(()),
3462 Err(source) => {
3463 return Err(WasmBuildError::Io {
3464 operation: "resolve Cargo configuration",
3465 path: config.to_owned(),
3466 source,
3467 });
3468 }
3469 };
3470 if !visited.insert(canonical.clone()) {
3471 return Ok(());
3472 }
3473
3474 let contents = fs::read_to_string(&canonical).map_err(|source| WasmBuildError::Io {
3475 operation: "read Cargo configuration",
3476 path: canonical.clone(),
3477 source,
3478 })?;
3479 let configuration = toml::from_str::<TomlValue>(&contents).map_err(|error| {
3480 WasmBuildError::InvalidCargoConfiguration {
3481 path: canonical.clone(),
3482 message: error.to_string(),
3483 }
3484 })?;
3485 inputs.push((
3486 cargo_configuration_label(&canonical, workspace_root),
3487 canonical.clone(),
3488 ));
3489
3490 let Some(include) = configuration.get("include") else {
3491 return Ok(());
3492 };
3493 let parent = canonical
3494 .parent()
3495 .ok_or_else(|| WasmBuildError::InvalidCargoConfiguration {
3496 path: canonical.clone(),
3497 message: "configuration path has no parent directory".to_owned(),
3498 })?;
3499 for (included, optional) in cargo_configuration_includes(include, &canonical)? {
3500 let included = if included.is_absolute() {
3501 included
3502 } else {
3503 parent.join(included)
3504 };
3505 append_cargo_configuration_tree(inputs, &included, workspace_root, visited, optional)?;
3506 }
3507 Ok(())
3508}
3509
3510fn cargo_configuration_includes(
3511 include: &TomlValue,
3512 config: &Path,
3513) -> Result<Vec<(PathBuf, bool)>, WasmBuildError> {
3514 let values = match include {
3515 TomlValue::Array(values) => values.as_slice(),
3516 value => std::slice::from_ref(value),
3517 };
3518 values
3519 .iter()
3520 .map(|value| match value {
3521 TomlValue::String(path) => Ok((PathBuf::from(path), false)),
3522 TomlValue::Table(table) => {
3523 let path = table
3524 .get("path")
3525 .and_then(TomlValue::as_str)
3526 .ok_or_else(|| {
3527 invalid_cargo_configuration(
3528 config,
3529 "Cargo configuration include table requires a string `path`",
3530 )
3531 })?;
3532 let optional = table
3533 .get("optional")
3534 .map(|value| {
3535 value.as_bool().ok_or_else(|| {
3536 invalid_cargo_configuration(
3537 config,
3538 "Cargo configuration include `optional` must be a boolean",
3539 )
3540 })
3541 })
3542 .transpose()?
3543 .unwrap_or(false);
3544 Ok((PathBuf::from(path), optional))
3545 }
3546 _ => Err(invalid_cargo_configuration(
3547 config,
3548 "Cargo configuration `include` must contain paths or include tables",
3549 )),
3550 })
3551 .collect()
3552}
3553
3554fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
3555 if let Ok(relative) = config.strip_prefix(workspace_root) {
3556 return PathBuf::from("cargo-config/workspace").join(relative);
3557 }
3558 let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
3559 PathBuf::from("cargo-config/external").join(location.to_hex())
3560}
3561
3562fn invalid_cargo_configuration(path: &Path, message: &str) -> WasmBuildError {
3563 WasmBuildError::InvalidCargoConfiguration {
3564 path: path.to_owned(),
3565 message: message.to_owned(),
3566 }
3567}
3568
3569fn append_package_inputs(
3570 inputs: &mut Vec<(PathBuf, PathBuf)>,
3571 packages: &HashMap<String, MetadataPackage>,
3572 closure: BTreeSet<String>,
3573 workspace_root: &Path,
3574) -> Result<(), WasmBuildError> {
3575 for id in closure {
3576 let Some(package) = packages.get(&id) else {
3577 return Err(invalid_metadata(&format!(
3578 "resolved package `{id}` is missing"
3579 )));
3580 };
3581 if !package.is_local {
3582 continue;
3583 }
3584 let root = package.manifest_path.parent().ok_or_else(|| {
3585 invalid_metadata(&format!(
3586 "package `{}` manifest has no parent",
3587 package.name
3588 ))
3589 })?;
3590 let relative_manifest = package
3591 .manifest_path
3592 .strip_prefix(workspace_root)
3593 .unwrap_or(&package.manifest_path);
3594 let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
3595 .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
3596 inputs.push((label, root.to_owned()));
3597 }
3598 Ok(())
3599}
3600
3601fn append_additional_inputs(
3602 inputs: &mut Vec<(PathBuf, PathBuf)>,
3603 spec: &WasmBuildSpec,
3604 workspace_root: &Path,
3605) {
3606 for additional in &spec.additional_inputs {
3607 let path = if additional.is_absolute() {
3608 additional.clone()
3609 } else {
3610 workspace_root.join(additional)
3611 };
3612 inputs.push((PathBuf::from("additional").join(additional), path));
3613 }
3614}
3615
3616fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
3617 let mut exclusions = vec![
3618 spec.target_dir.clone(),
3619 spec.workspace_root.join("target"),
3620 spec.workspace_root.join(".git"),
3621 ];
3622 if let Some(shared_target) = shared_incremental_target(spec) {
3623 exclusions.push(shared_target);
3624 }
3625 for (_, path) in inputs {
3626 if path.is_dir() {
3627 exclusions.push(path.join("target"));
3628 exclusions.push(path.join(".git"));
3629 }
3630 }
3631 exclusions
3632}
3633
3634fn validate_shared_incremental_target_boundary(
3635 spec: &WasmBuildSpec,
3636 inputs: &[(PathBuf, PathBuf)],
3637) -> Result<(), WasmBuildError> {
3638 let Some(shared_target) = shared_incremental_target(spec) else {
3639 return Ok(());
3640 };
3641 let shared_target =
3642 canonicalize_allow_missing(&shared_target).map_err(|source| WasmBuildError::Io {
3643 operation: "resolve shared incremental Cargo target boundary",
3644 path: shared_target.clone(),
3645 source,
3646 })?;
3647 let resolved_inputs = inputs
3648 .iter()
3649 .map(|(_, input)| {
3650 let canonical = input.canonicalize().map_err(|source| WasmBuildError::Io {
3651 operation: "resolve Cargo input boundary",
3652 path: input.clone(),
3653 source,
3654 })?;
3655 let metadata = fs::metadata(&canonical).map_err(|source| WasmBuildError::Io {
3656 operation: "inspect Cargo input boundary",
3657 path: canonical.clone(),
3658 source,
3659 })?;
3660 Ok((canonical, metadata.is_dir()))
3661 })
3662 .collect::<Result<Vec<_>, WasmBuildError>>()?;
3663 let safe_generated_roots = std::iter::once(spec.target_dir.clone())
3664 .chain(std::iter::once(spec.workspace_root.join("target")))
3665 .chain(
3666 inputs
3667 .iter()
3668 .filter(|(_, path)| path.is_dir())
3669 .map(|(_, path)| path.join("target")),
3670 )
3671 .filter_map(|path| canonicalize_allow_missing(&path).ok())
3672 .filter(|root| {
3673 !resolved_inputs
3674 .iter()
3675 .any(|(input, _is_directory)| input.starts_with(root))
3676 })
3677 .collect::<Vec<_>>();
3678 if safe_generated_roots
3679 .iter()
3680 .any(|root| shared_target.starts_with(root))
3681 {
3682 return Ok(());
3683 }
3684
3685 for (input, is_directory) in resolved_inputs {
3686 if shared_target == input
3687 || (is_directory && shared_target.starts_with(&input))
3688 || input.starts_with(&shared_target)
3689 {
3690 return Err(WasmBuildError::InvalidSpec {
3691 message: format!(
3692 "shared incremental target {} must not overlap exact Cargo inputs unless it is inside a generated target directory",
3693 shared_target.display()
3694 ),
3695 });
3696 }
3697 }
3698 Ok(())
3699}
3700
3701fn canonicalize_allow_missing(path: &Path) -> io::Result<PathBuf> {
3702 let absolute = if path.is_absolute() {
3703 path.to_owned()
3704 } else {
3705 std::env::current_dir()?.join(path)
3706 };
3707 let mut unresolved = Vec::<OsString>::new();
3708 let mut existing = absolute.as_path();
3709 loop {
3710 match existing.canonicalize() {
3711 Ok(mut canonical) => {
3712 for component in unresolved.into_iter().rev() {
3713 canonical.push(component);
3714 }
3715 return Ok(canonical);
3716 }
3717 Err(error) if error.kind() == io::ErrorKind::NotFound => {
3718 let Some(name) = existing.file_name() else {
3719 return Err(error);
3720 };
3721 unresolved.push(name.to_owned());
3722 existing = existing.parent().ok_or(error)?;
3723 }
3724 Err(error) => return Err(error),
3725 }
3726 }
3727}
3728
3729fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
3730 let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
3731 return None;
3732 };
3733 Some(if target_dir.is_absolute() {
3734 target_dir.clone()
3735 } else {
3736 spec.workspace_root.join(target_dir)
3737 })
3738}
3739
3740fn shared_incremental_target_exists(
3741 spec: &WasmBuildSpec,
3742 operation: &'static str,
3743) -> Result<bool, WasmBuildError> {
3744 let target_dir =
3745 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
3746 message: "shared incremental target is not configured".to_owned(),
3747 })?;
3748 match fs::symlink_metadata(&target_dir) {
3749 Ok(metadata) if metadata.is_dir() => Ok(true),
3750 Ok(_) => Err(WasmBuildError::InvalidSpec {
3751 message: format!(
3752 "shared incremental Cargo target {} must be a directory",
3753 target_dir.display()
3754 ),
3755 }),
3756 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
3757 Err(source) => Err(WasmBuildError::Io {
3758 operation,
3759 path: target_dir,
3760 source,
3761 }),
3762 }
3763}
3764
3765fn effective_environment(spec: &WasmBuildSpec) -> BTreeMap<OsString, Option<OsString>> {
3766 let mut names = spec.inherited_env.clone();
3767 names.extend(AUTOMATIC_ENVIRONMENT.iter().map(OsString::from));
3768 let mut environment = names
3769 .into_iter()
3770 .map(|name| {
3771 let value = std::env::var_os(&name);
3772 (name, value)
3773 })
3774 .collect::<BTreeMap<_, _>>();
3775 for (key, value) in &spec.extra_env {
3776 environment.insert(key.clone(), Some(value.clone()));
3777 }
3778 environment
3779}
3780
3781fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
3782 for (key, value) in &spec.extra_env {
3783 command.env(key, value);
3784 }
3785}
3786
3787fn run_cargo_build(
3788 spec: &WasmBuildSpec,
3789 build_target_dir: &Path,
3790 progress: &mut ProgressReporter<'_>,
3791) -> Result<(), WasmBuildError> {
3792 let mut command = Command::new(&spec.cargo_program);
3793 command
3794 .current_dir(&spec.workspace_root)
3795 .env("CARGO_TARGET_DIR", build_target_dir)
3796 .args(["build", "--target", &spec.target])
3797 .args(&spec.cargo_profile_args);
3798 apply_command_environment(&mut command, spec);
3799 for package in &spec.packages {
3800 command.args(["-p", package]);
3801 }
3802
3803 if !progress.is_observed() {
3804 let output = command
3805 .output()
3806 .map_err(|source| WasmBuildError::CommandSpawn {
3807 phase: WasmBuildPhase::CargoBuild,
3808 program: spec.cargo_program.clone(),
3809 source,
3810 })?;
3811 return ensure_command_success(WasmBuildPhase::CargoBuild, output).map(|_| ());
3812 }
3813
3814 run_observed_cargo_build(spec, build_target_dir, command, progress)
3815}
3816
3817fn run_observed_cargo_build(
3818 spec: &WasmBuildSpec,
3819 build_target_dir: &Path,
3820 mut command: Command,
3821 progress: &mut ProgressReporter<'_>,
3822) -> Result<(), WasmBuildError> {
3823 command.stdout(Stdio::piped()).stderr(Stdio::piped());
3824 let started = Instant::now();
3825 let child = command
3826 .spawn()
3827 .map_err(|source| WasmBuildError::CommandSpawn {
3828 phase: WasmBuildPhase::CargoBuild,
3829 program: spec.cargo_program.clone(),
3830 source,
3831 })?;
3832 let mut child = ObservedChild::new(child);
3833 progress.emit(WasmBuildProgressEvent::CargoStarted {
3834 target_dir: build_target_dir.to_owned(),
3835 });
3836
3837 let stdout = child
3838 .child_mut()
3839 .stdout
3840 .take()
3841 .expect("Cargo stdout must be piped");
3842 let stderr = child
3843 .child_mut()
3844 .stderr
3845 .take()
3846 .expect("Cargo stderr must be piped");
3847 let (sender, chunks) = mpsc::channel();
3848 let stdout_sender = sender.clone();
3849 let stdout_reader = thread::spawn(move || {
3850 read_process_output(stdout, WasmBuildOutputStream::Stdout, stdout_sender)
3851 });
3852 let stderr_reader =
3853 thread::spawn(move || read_process_output(stderr, WasmBuildOutputStream::Stderr, sender));
3854
3855 let captured = capture_observed_cargo_output(chunks, progress, started);
3856
3857 let status = child.wait().map_err(|source| WasmBuildError::Io {
3858 operation: "wait for observed cargo build",
3859 path: PathBuf::from(&spec.cargo_program),
3860 source,
3861 })?;
3862 join_output_reader(
3863 stdout_reader,
3864 "read observed cargo stdout",
3865 &spec.cargo_program,
3866 )?;
3867 join_output_reader(
3868 stderr_reader,
3869 "read observed cargo stderr",
3870 &spec.cargo_program,
3871 )?;
3872 let elapsed = started.elapsed();
3873 progress.emit(WasmBuildProgressEvent::CargoFinished {
3874 success: status.success(),
3875 code: status.code(),
3876 elapsed,
3877 });
3878
3879 ensure_command_success(
3880 WasmBuildPhase::CargoBuild,
3881 Output {
3882 status,
3883 stdout: captured.stdout,
3884 stderr: captured.stderr,
3885 },
3886 )
3887 .map(|_| ())
3888}
3889
3890struct CapturedProcessOutput {
3891 stdout: Vec<u8>,
3892 stderr: Vec<u8>,
3893}
3894
3895fn capture_observed_cargo_output(
3896 chunks: mpsc::Receiver<ProcessOutputChunk>,
3897 progress: &mut ProgressReporter<'_>,
3898 started: Instant,
3899) -> CapturedProcessOutput {
3900 let mut stdout = Vec::new();
3901 let mut stderr = Vec::new();
3902 loop {
3903 let message = match progress.heartbeat_due_in() {
3904 Some(wait) => match chunks.recv_timeout(wait) {
3905 Ok(chunk) => Some(chunk),
3906 Err(RecvTimeoutError::Timeout) => {
3907 progress.emit_heartbeat(WasmBuildProgressPhase::CargoBuild, started.elapsed());
3908 None
3909 }
3910 Err(RecvTimeoutError::Disconnected) => break,
3911 },
3912 None => match chunks.recv() {
3913 Ok(chunk) => Some(chunk),
3914 Err(_) => break,
3915 },
3916 };
3917 let Some(chunk) = message else {
3918 continue;
3919 };
3920 match chunk.stream {
3921 WasmBuildOutputStream::Stdout => stdout.extend_from_slice(&chunk.bytes),
3922 WasmBuildOutputStream::Stderr => stderr.extend_from_slice(&chunk.bytes),
3923 }
3924 if progress.config.emit_cargo_output {
3925 progress.emit(WasmBuildProgressEvent::CargoOutput {
3926 stream: chunk.stream,
3927 bytes: chunk.bytes,
3928 });
3929 }
3930 }
3931 CapturedProcessOutput { stdout, stderr }
3932}
3933
3934#[derive(Debug)]
3935struct ProcessOutputChunk {
3936 stream: WasmBuildOutputStream,
3937 bytes: Vec<u8>,
3938}
3939
3940fn read_process_output<R: io::Read>(
3941 mut reader: R,
3942 stream: WasmBuildOutputStream,
3943 sender: mpsc::Sender<ProcessOutputChunk>,
3944) -> io::Result<()> {
3945 let mut buffer = [0_u8; 8 * 1024];
3946 loop {
3947 let count = reader.read(&mut buffer)?;
3948 if count == 0 {
3949 return Ok(());
3950 }
3951 if sender
3952 .send(ProcessOutputChunk {
3953 stream,
3954 bytes: buffer[..count].to_vec(),
3955 })
3956 .is_err()
3957 {
3958 return Ok(());
3959 }
3960 }
3961}
3962
3963fn join_output_reader(
3964 reader: thread::JoinHandle<io::Result<()>>,
3965 operation: &'static str,
3966 cargo_program: &OsStr,
3967) -> Result<(), WasmBuildError> {
3968 let result = reader.join().map_err(|_| WasmBuildError::Io {
3969 operation,
3970 path: PathBuf::from(cargo_program),
3971 source: io::Error::other("Cargo output reader panicked"),
3972 })?;
3973 result.map_err(|source| WasmBuildError::Io {
3974 operation,
3975 path: PathBuf::from(cargo_program),
3976 source,
3977 })
3978}
3979
3980struct ObservedChild(Option<Child>);
3981
3982impl ObservedChild {
3983 const fn new(child: Child) -> Self {
3984 Self(Some(child))
3985 }
3986
3987 const fn child_mut(&mut self) -> &mut Child {
3988 self.0.as_mut().expect("observed child must be present")
3989 }
3990
3991 fn wait(&mut self) -> io::Result<ExitStatus> {
3992 let status = self.child_mut().wait()?;
3993 self.0.take();
3994 Ok(status)
3995 }
3996}
3997
3998impl Drop for ObservedChild {
3999 fn drop(&mut self) {
4000 if let Some(mut child) = self.0.take() {
4001 let _ = child.kill();
4002 let _ = child.wait();
4003 }
4004 }
4005}
4006
4007fn ensure_command_success(phase: WasmBuildPhase, output: Output) -> Result<Output, WasmBuildError> {
4008 if output.status.success() {
4009 return Ok(output);
4010 }
4011 Err(WasmBuildError::CommandFailed {
4012 phase,
4013 status: output.status,
4014 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
4015 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
4016 })
4017}
4018
4019fn expected_artifacts(spec: &WasmBuildSpec, target_dir: &Path) -> Vec<PathBuf> {
4020 let mut packages = spec.packages.iter().map(String::as_str).collect::<Vec<_>>();
4021 packages.sort_unstable();
4022 packages.dedup();
4023 packages
4024 .into_iter()
4025 .map(|package| {
4026 if spec.target == DEFAULT_TARGET {
4027 wasm_path(target_dir, package, &spec.profile_target_dir)
4028 } else {
4029 target_dir
4030 .join(&spec.target)
4031 .join(&spec.profile_target_dir)
4032 .join(format!("{package}.wasm"))
4033 }
4034 })
4035 .collect()
4036}
4037
4038fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
4039 spec.target_dir
4040 .join(".ic-testkit/wasm-targets")
4041 .join(fingerprint.to_hex())
4042}
4043
4044fn artifact_set_matches(artifacts: &[PathBuf], fingerprint: InputDigest) -> bool {
4045 artifacts.iter().all(|path| {
4046 fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
4047 && cache_stamp_matches(path, fingerprint)
4048 })
4049}
4050
4051fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
4052 artifacts
4053 .iter()
4054 .filter(|path| {
4055 fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
4056 })
4057 .cloned()
4058 .collect()
4059}
4060
4061fn cache_stamp_matches(artifact: &Path, fingerprint: InputDigest) -> bool {
4062 let stamp_path = artifact_stamp_path(artifact);
4063 let Ok(expected) = artifact_stamp_contents(artifact, fingerprint) else {
4064 return false;
4065 };
4066 fs::read_to_string(stamp_path).is_ok_and(|stamp| stamp == expected)
4067}
4068
4069fn artifact_stamp_path(artifact: &Path) -> PathBuf {
4070 let mut name = artifact
4071 .file_name()
4072 .map_or_else(|| OsString::from("artifact"), OsString::from);
4073 name.push(".ic-testkit-build");
4074 artifact.with_file_name(name)
4075}
4076
4077fn artifact_stamp_contents(artifact: &Path, fingerprint: InputDigest) -> io::Result<String> {
4078 let (_, artifact_digest) = digest_file("wasm-artifact-v1", artifact)?;
4079 Ok(format!(
4080 "{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\nartifact-sha256:{artifact_digest}\n"
4081 ))
4082}
4083
4084fn publish_artifact_stamps(
4085 artifacts: &[PathBuf],
4086 fingerprint: InputDigest,
4087) -> Result<(), WasmBuildError> {
4088 for artifact in artifacts {
4089 let stamp_path = artifact_stamp_path(artifact);
4090 let stamp = artifact_stamp_contents(artifact, fingerprint).map_err(|source| {
4091 WasmBuildError::Io {
4092 operation: "hash built Wasm artifact",
4093 path: artifact.clone(),
4094 source,
4095 }
4096 })?;
4097 write_atomic(&stamp_path, stamp.as_bytes()).map_err(|source| WasmBuildError::Io {
4098 operation: "publish Wasm build stamp",
4099 path: stamp_path,
4100 source,
4101 })?;
4102 }
4103 Ok(())
4104}
4105
4106fn materialize_artifacts(
4107 cached_artifacts: &[PathBuf],
4108 artifacts: &[PathBuf],
4109 fingerprint: InputDigest,
4110) -> Result<(), WasmBuildError> {
4111 for (cached, artifact) in cached_artifacts.iter().zip(artifacts) {
4112 copy_file_atomic(cached, artifact).map_err(|source| WasmBuildError::Io {
4113 operation: "publish Wasm artifact",
4114 path: artifact.clone(),
4115 source,
4116 })?;
4117 }
4118 publish_artifact_stamps(artifacts, fingerprint)
4119}
4120
4121fn copy_wasm_artifacts(
4122 source_artifacts: &[PathBuf],
4123 cached_artifacts: &[PathBuf],
4124) -> Result<(), WasmBuildError> {
4125 for (source, cached) in source_artifacts.iter().zip(cached_artifacts) {
4126 copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
4127 operation: "cache shared-incremental Wasm artifact",
4128 path: cached.clone(),
4129 source: source_error,
4130 })?;
4131 }
4132 Ok(())
4133}
4134
4135fn remove_directory_if_present(path: &Path) -> Result<(), WasmBuildError> {
4136 remove_path_if_present(path).map_err(|source| WasmBuildError::Io {
4137 operation: "remove incomplete content-addressed Cargo target directory",
4138 path: path.to_owned(),
4139 source,
4140 })
4141}
4142
4143fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
4144 fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
4145 operation,
4146 path: path.to_owned(),
4147 source,
4148 })
4149}
4150
4151fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
4152 if path.exists() {
4153 inputs.push((PathBuf::from(label), path));
4154 }
4155}
4156
4157fn required_string(value: &Value, field: &str) -> Result<String, WasmBuildError> {
4158 value
4159 .get(field)
4160 .and_then(Value::as_str)
4161 .map(str::to_owned)
4162 .ok_or_else(|| invalid_metadata(&format!("Cargo metadata field `{field}` is missing")))
4163}
4164
4165fn optional_string(value: &Value, field: &str) -> Result<Option<String>, WasmBuildError> {
4166 match value.get(field) {
4167 None | Some(Value::Null) => Ok(None),
4168 Some(Value::String(value)) => Ok(Some(value.clone())),
4169 Some(_) => Err(invalid_metadata(&format!(
4170 "Cargo metadata field `{field}` is not a string or null"
4171 ))),
4172 }
4173}
4174
4175fn invalid_metadata(message: &str) -> WasmBuildError {
4176 WasmBuildError::InvalidMetadata {
4177 message: message.to_owned(),
4178 }
4179}
4180
4181impl std::fmt::Display for WasmBuildPhase {
4182 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4183 formatter.write_str(match self {
4184 Self::CargoMetadata => "cargo metadata",
4185 Self::CargoIdentity => "Cargo identity",
4186 Self::RustcIdentity => "Rust compiler identity",
4187 Self::CargoBuild => "cargo build",
4188 })
4189 }
4190}
4191
4192impl std::fmt::Display for WasmBuildProgressPhase {
4193 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4194 formatter.write_str(match self {
4195 Self::ExactCacheLock => "exact cache lock",
4196 Self::CargoIdentity => "Cargo identity",
4197 Self::RustcIdentity => "Rust compiler identity",
4198 Self::CargoMetadata => "Cargo metadata",
4199 Self::InputDiscovery => "input discovery",
4200 Self::ContentHashing => "content hashing",
4201 Self::SharedTargetLock => "shared target lock",
4202 Self::SharedTargetMaintenance => "shared target maintenance",
4203 Self::CargoBuild => "Cargo build",
4204 Self::ArtifactPublication => "artifact publication",
4205 Self::ExactCacheMaintenance => "exact cache maintenance",
4206 })
4207 }
4208}
4209
4210impl std::fmt::Display for WasmBuildError {
4211 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4212 match self {
4213 Self::InvalidSpec { message } => {
4214 write!(formatter, "invalid Wasm build spec: {message}")
4215 }
4216 Self::Io {
4217 operation,
4218 path,
4219 source,
4220 } => write!(
4221 formatter,
4222 "failed to {operation} at {}: {source}",
4223 path.display()
4224 ),
4225 Self::CommandSpawn {
4226 phase,
4227 program,
4228 source,
4229 } => write!(
4230 formatter,
4231 "failed to launch {phase} using `{}`: {source}",
4232 program.to_string_lossy(),
4233 ),
4234 Self::CommandFailed {
4235 phase,
4236 status,
4237 stdout,
4238 stderr,
4239 } => write!(
4240 formatter,
4241 "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
4242 ),
4243 Self::InvalidMetadata { message } => {
4244 write!(formatter, "invalid Cargo metadata: {message}")
4245 }
4246 Self::InvalidCargoConfiguration { path, message } => write!(
4247 formatter,
4248 "invalid Cargo configuration at {}: {message}",
4249 path.display(),
4250 ),
4251 Self::MissingArtifacts { paths } => write!(
4252 formatter,
4253 "cargo build succeeded without producing: {}",
4254 paths
4255 .iter()
4256 .map(|path| path.display().to_string())
4257 .collect::<Vec<_>>()
4258 .join(", "),
4259 ),
4260 Self::InputsChangedDuringBuild { before, after } => write!(
4261 formatter,
4262 "Wasm build inputs changed while Cargo was running: {before} -> {after}",
4263 ),
4264 Self::FailedBuildCleanup {
4265 build_error,
4266 path,
4267 source,
4268 } => write!(
4269 formatter,
4270 "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
4271 path.display(),
4272 ),
4273 }
4274 }
4275}
4276
4277impl std::error::Error for WasmBuildError {
4278 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
4279 match self {
4280 Self::Io { source, .. }
4281 | Self::CommandSpawn { source, .. }
4282 | Self::FailedBuildCleanup { source, .. } => Some(source),
4283 _ => None,
4284 }
4285 }
4286}
4287
4288#[cfg(test)]
4289mod tests;