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 super::{
16 cache_fs::{
17 ArtifactCacheMaintenance, ArtifactCachePrunePolicy, ArtifactCachePruneReport, CacheFsError,
18 cache_entry_last_used, cache_maintenance_due, directory_logical_size,
19 ensure_cache_directory_tag as ensure_cache_tag, is_sha256_directory, lock_cache_file,
20 perform_scheduled_cache_maintenance, prune_direct_child_directories,
21 record_cache_entry_use as record_entry_use, record_cache_maintenance,
22 remove_path_if_present,
23 },
24 digest::{
25 InputDigest, InputHasher, copy_file_atomic, digest_bytes, digest_file,
26 digest_labeled_paths, os_bytes, write_atomic,
27 },
28 wasm::wasm_path,
29};
30
31const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
32const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
33const AUTOMATIC_ENVIRONMENT: &[&str] = &[
34 "CARGO_BUILD_RUSTC",
35 "CARGO_ENCODED_RUSTFLAGS",
36 "RUSTC",
37 "RUSTC_WRAPPER",
38 "RUSTC_WORKSPACE_WRAPPER",
39 "RUSTFLAGS",
40 "RUSTUP_TOOLCHAIN",
41];
42
43#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct WasmBuildSpec {
51 workspace_root: PathBuf,
52 target_dir: PathBuf,
53 packages: Vec<String>,
54 profile_target_dir: String,
55 cargo_profile_args: Vec<OsString>,
56 extra_env: BTreeMap<OsString, OsString>,
57 inherited_env: BTreeSet<OsString>,
58 additional_inputs: Vec<PathBuf>,
59 target: String,
60 cargo_program: OsString,
61 rustc_program: OsString,
62 cache_mode: WasmBuildCacheMode,
63 prune_policy: Option<WasmBuildCachePrunePolicy>,
64 prune_interval: Option<Duration>,
65}
66
67#[non_exhaustive]
69#[derive(Clone, Debug, Eq, PartialEq)]
70pub enum WasmBuildCacheMode {
71 Isolated,
73 SharedIncremental {
75 target_dir: PathBuf,
77 },
78}
79
80#[derive(Clone, Debug, Eq, PartialEq)]
82pub enum WasmBuildOutcome {
83 Built(WasmBuildRecord),
85 Reused(WasmBuildRecord),
87}
88
89#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct WasmBuildRecord {
92 fingerprint: InputDigest,
93 input_digest: InputDigest,
94 artifacts: Vec<PathBuf>,
95 timings: WasmBuildTimings,
96 maintenance: Option<WasmBuildCacheMaintenance>,
97}
98
99#[derive(Clone, Copy, Debug, Eq, PartialEq)]
101pub struct WasmBuildTimings {
102 lock_wait: Duration,
103 shared_incremental_lock_wait: Option<Duration>,
104 input_resolution: WasmInputResolutionTimings,
105 cargo_build: Option<Duration>,
106 cache_maintenance: Option<Duration>,
107 total: Duration,
108}
109
110#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
112pub struct WasmInputResolutionTimings {
113 tool_identity: Duration,
114 cargo_metadata: Duration,
115 input_discovery: Duration,
116 content_hashing: Duration,
117 total: Duration,
118}
119
120#[derive(Clone, Debug, Eq, PartialEq)]
122pub struct CargoBuildInput {
123 label: PathBuf,
124 path: PathBuf,
125}
126
127#[derive(Clone, Debug, Eq, PartialEq)]
132pub struct ResolvedCargoBuildInputs {
133 fingerprint: InputDigest,
134 input_digest: InputDigest,
135 inputs: Vec<CargoBuildInput>,
136 exclusions: Vec<PathBuf>,
137 timings: WasmInputResolutionTimings,
138}
139
140#[derive(Clone, Debug, Eq, PartialEq)]
142pub struct SharedIncrementalTargetInspection {
143 target_dir: PathBuf,
144 logical_size_bytes: u64,
145 last_used: SystemTime,
146 lock_wait: Duration,
147}
148
149#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
157pub struct SharedIncrementalTargetPrunePolicy {
158 max_age: Option<Duration>,
159 max_size_bytes: Option<u64>,
160}
161
162#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct SharedIncrementalTargetMaintenance {
165 target_dir: PathBuf,
166 logical_size_bytes_before: u64,
167 logical_size_bytes_after: u64,
168 last_used_before: SystemTime,
169 cleared: bool,
170 lock_wait: Duration,
171 maintenance: Duration,
172}
173
174#[non_exhaustive]
176#[derive(Clone, Debug, Eq, PartialEq)]
177pub enum SharedIncrementalTargetMaintenanceOutcome {
178 Missing {
180 target_dir: PathBuf,
182 },
183 Skipped {
185 target_dir: PathBuf,
187 lock_wait: Duration,
189 schedule_check: Duration,
191 },
192 Performed {
194 maintenance: SharedIncrementalTargetMaintenance,
196 schedule_check: Duration,
198 },
199}
200
201#[derive(Clone, Copy, Debug, Eq, PartialEq)]
203pub struct WasmBuildProgressConfig {
204 heartbeat_interval: Option<Duration>,
205 emit_cargo_output: bool,
206}
207
208#[derive(Clone, Copy, Debug, Eq, PartialEq)]
210pub enum WasmBuildOutputStream {
211 Stdout,
213 Stderr,
215}
216
217#[derive(Clone, Copy, Debug, Eq, PartialEq)]
219pub enum WasmBuildProgressOutcome {
220 Built,
222 Reused,
224}
225
226#[non_exhaustive]
228#[derive(Clone, Debug, Eq, PartialEq)]
229pub enum WasmBuildProgressEvent {
230 Started,
232 InputsResolved {
234 fingerprint: InputDigest,
236 input_digest: InputDigest,
238 elapsed: Duration,
240 },
241 CacheMiss {
243 fingerprint: InputDigest,
245 },
246 CacheHit {
248 fingerprint: InputDigest,
250 },
251 SharedTargetLockStarted {
253 target_dir: PathBuf,
255 },
256 SharedTargetLockAcquired {
258 target_dir: PathBuf,
260 wait: Duration,
262 },
263 CargoStarted {
265 target_dir: PathBuf,
267 },
268 CargoOutput {
270 stream: WasmBuildOutputStream,
272 bytes: Vec<u8>,
274 },
275 CargoHeartbeat {
277 elapsed: Duration,
279 },
280 CargoFinished {
282 success: bool,
284 code: Option<i32>,
286 elapsed: Duration,
288 },
289 Finished {
291 outcome: WasmBuildProgressOutcome,
293 fingerprint: InputDigest,
295 elapsed: Duration,
297 },
298}
299
300impl Default for WasmBuildProgressConfig {
301 fn default() -> Self {
302 Self {
303 heartbeat_interval: Some(Duration::from_secs(10)),
304 emit_cargo_output: true,
305 }
306 }
307}
308
309impl WasmBuildProgressConfig {
310 #[must_use]
312 pub fn new() -> Self {
313 Self::default()
314 }
315
316 #[must_use]
320 pub const fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
321 self.heartbeat_interval = Some(interval);
322 self
323 }
324
325 #[must_use]
327 pub const fn without_heartbeats(mut self) -> Self {
328 self.heartbeat_interval = None;
329 self
330 }
331
332 #[must_use]
336 pub const fn with_cargo_output(mut self, emit: bool) -> Self {
337 self.emit_cargo_output = emit;
338 self
339 }
340
341 #[must_use]
343 pub const fn heartbeat_interval(self) -> Option<Duration> {
344 self.heartbeat_interval
345 }
346
347 #[must_use]
349 pub const fn emits_cargo_output(self) -> bool {
350 self.emit_cargo_output
351 }
352}
353
354struct ProgressReporter<'a> {
355 config: WasmBuildProgressConfig,
356 observer: Option<&'a mut dyn FnMut(WasmBuildProgressEvent)>,
357}
358
359impl ProgressReporter<'_> {
360 fn silent() -> Self {
361 Self {
362 config: WasmBuildProgressConfig {
363 heartbeat_interval: None,
364 emit_cargo_output: false,
365 },
366 observer: None,
367 }
368 }
369
370 fn emit(&mut self, event: WasmBuildProgressEvent) {
371 if let Some(observer) = &mut self.observer {
372 observer(event);
373 }
374 }
375
376 const fn is_observed(&self) -> bool {
377 self.observer.is_some()
378 }
379}
380
381pub type WasmBuildCachePrunePolicy = ArtifactCachePrunePolicy;
383
384pub type WasmBuildCachePruneReport = ArtifactCachePruneReport;
386
387pub type WasmBuildCacheMaintenance = ArtifactCacheMaintenance;
389
390#[non_exhaustive]
392#[derive(Clone, Copy, Debug, Eq, PartialEq)]
393pub enum WasmBuildPhase {
394 CargoMetadata,
396 CargoIdentity,
398 RustcIdentity,
400 CargoBuild,
402}
403
404#[non_exhaustive]
406#[derive(Debug)]
407pub enum WasmBuildError {
408 InvalidSpec { message: String },
410 Io {
412 operation: &'static str,
413 path: PathBuf,
414 source: io::Error,
415 },
416 CommandSpawn {
418 phase: WasmBuildPhase,
419 program: OsString,
420 source: io::Error,
421 },
422 CommandFailed {
424 phase: WasmBuildPhase,
425 status: ExitStatus,
426 stdout: String,
427 stderr: String,
428 },
429 InvalidMetadata { message: String },
431 InvalidCargoConfiguration { path: PathBuf, message: String },
433 MissingArtifacts { paths: Vec<PathBuf> },
435 InputsChangedDuringBuild {
437 before: InputDigest,
438 after: InputDigest,
439 },
440 FailedBuildCleanup {
442 build_error: Box<Self>,
443 path: PathBuf,
444 source: io::Error,
445 },
446}
447
448impl WasmBuildSpec {
449 #[must_use]
454 pub fn new(
455 workspace_root: &Path,
456 target_dir: &Path,
457 packages: &[&str],
458 profile_target_dir: &str,
459 ) -> Self {
460 Self {
461 workspace_root: workspace_root.to_owned(),
462 target_dir: target_dir.to_owned(),
463 packages: packages
464 .iter()
465 .map(|package| (*package).to_owned())
466 .collect(),
467 profile_target_dir: profile_target_dir.to_owned(),
468 cargo_profile_args: Vec::new(),
469 extra_env: BTreeMap::new(),
470 inherited_env: BTreeSet::new(),
471 additional_inputs: Vec::new(),
472 target: DEFAULT_TARGET.to_owned(),
473 cargo_program: std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()),
474 rustc_program: std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()),
475 cache_mode: WasmBuildCacheMode::Isolated,
476 prune_policy: None,
477 prune_interval: None,
478 }
479 }
480
481 #[must_use]
483 pub fn with_cargo_profile_args(mut self, arguments: &[&str]) -> Self {
484 self.cargo_profile_args = arguments.iter().map(OsString::from).collect();
485 self
486 }
487
488 #[must_use]
490 pub fn with_cargo_profile_args_os<I, S>(mut self, arguments: I) -> Self
491 where
492 I: IntoIterator<Item = S>,
493 S: Into<OsString>,
494 {
495 self.cargo_profile_args = arguments.into_iter().map(Into::into).collect();
496 self
497 }
498
499 #[must_use]
501 pub fn with_extra_env(mut self, environment: &[(&str, &str)]) -> Self {
502 self.extra_env = environment
503 .iter()
504 .map(|(key, value)| (OsString::from(key), OsString::from(value)))
505 .collect();
506 self
507 }
508
509 #[must_use]
511 pub fn with_extra_env_os<I, K, V>(mut self, environment: I) -> Self
512 where
513 I: IntoIterator<Item = (K, V)>,
514 K: Into<OsString>,
515 V: Into<OsString>,
516 {
517 self.extra_env = environment
518 .into_iter()
519 .map(|(key, value)| (key.into(), value.into()))
520 .collect();
521 self
522 }
523
524 #[must_use]
529 pub fn with_inherited_env(mut self, names: &[&str]) -> Self {
530 self.inherited_env.extend(names.iter().map(OsString::from));
531 self
532 }
533
534 #[must_use]
536 pub fn with_inherited_env_os<I, S>(mut self, names: I) -> Self
537 where
538 I: IntoIterator<Item = S>,
539 S: Into<OsString>,
540 {
541 self.inherited_env.extend(names.into_iter().map(Into::into));
542 self
543 }
544
545 #[must_use]
550 pub fn with_additional_inputs(mut self, paths: &[&str]) -> Self {
551 self.additional_inputs
552 .extend(paths.iter().map(PathBuf::from));
553 self
554 }
555
556 #[must_use]
558 pub fn with_additional_input_paths<I, P>(mut self, paths: I) -> Self
559 where
560 I: IntoIterator<Item = P>,
561 P: Into<PathBuf>,
562 {
563 self.additional_inputs
564 .extend(paths.into_iter().map(Into::into));
565 self
566 }
567
568 #[must_use]
570 pub fn with_target(mut self, target: &str) -> Self {
571 target.clone_into(&mut self.target);
572 self
573 }
574
575 #[must_use]
577 pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
578 self.cargo_program = program.into();
579 self
580 }
581
582 #[must_use]
584 pub fn with_rustc_program(mut self, program: impl Into<OsString>) -> Self {
585 self.rustc_program = program.into();
586 self
587 }
588
589 #[must_use]
595 pub fn with_shared_incremental_target(mut self, target_dir: impl Into<PathBuf>) -> Self {
596 self.cache_mode = WasmBuildCacheMode::SharedIncremental {
597 target_dir: target_dir.into(),
598 };
599 self
600 }
601
602 #[must_use]
608 pub const fn with_prune_policy(mut self, policy: WasmBuildCachePrunePolicy) -> Self {
609 self.prune_policy = Some(policy);
610 self.prune_interval = None;
611 self
612 }
613
614 #[must_use]
621 pub const fn with_prune_policy_at_most_every(
622 mut self,
623 policy: WasmBuildCachePrunePolicy,
624 minimum_interval: Duration,
625 ) -> Self {
626 self.prune_policy = Some(policy);
627 self.prune_interval = Some(minimum_interval);
628 self
629 }
630
631 #[must_use]
633 pub fn workspace_root(&self) -> &Path {
634 &self.workspace_root
635 }
636
637 #[must_use]
639 pub fn target_dir(&self) -> &Path {
640 &self.target_dir
641 }
642
643 #[must_use]
645 pub fn packages(&self) -> &[String] {
646 &self.packages
647 }
648
649 #[must_use]
651 pub const fn cache_mode(&self) -> &WasmBuildCacheMode {
652 &self.cache_mode
653 }
654}
655
656impl WasmBuildOutcome {
657 #[must_use]
659 pub const fn record(&self) -> &WasmBuildRecord {
660 match self {
661 Self::Built(record) | Self::Reused(record) => record,
662 }
663 }
664
665 #[must_use]
667 pub const fn is_reused(&self) -> bool {
668 matches!(self, Self::Reused(_))
669 }
670}
671
672impl WasmBuildRecord {
673 #[must_use]
675 pub const fn fingerprint(&self) -> InputDigest {
676 self.fingerprint
677 }
678
679 #[must_use]
681 pub const fn input_digest(&self) -> InputDigest {
682 self.input_digest
683 }
684
685 #[must_use]
687 pub fn artifacts(&self) -> &[PathBuf] {
688 &self.artifacts
689 }
690
691 #[must_use]
693 pub const fn timings(&self) -> WasmBuildTimings {
694 self.timings
695 }
696
697 #[must_use]
699 pub const fn maintenance(&self) -> Option<&WasmBuildCacheMaintenance> {
700 self.maintenance.as_ref()
701 }
702}
703
704impl WasmBuildTimings {
705 #[must_use]
707 pub const fn lock_wait(self) -> Duration {
708 self.lock_wait
709 }
710
711 #[must_use]
713 pub const fn shared_incremental_lock_wait(self) -> Option<Duration> {
714 self.shared_incremental_lock_wait
715 }
716
717 #[must_use]
719 pub const fn input_resolution(self) -> Duration {
720 self.input_resolution.total
721 }
722
723 #[must_use]
725 pub const fn input_resolution_detail(self) -> WasmInputResolutionTimings {
726 self.input_resolution
727 }
728
729 #[must_use]
731 pub const fn cargo_build(self) -> Option<Duration> {
732 self.cargo_build
733 }
734
735 #[must_use]
737 pub const fn cache_maintenance(self) -> Option<Duration> {
738 self.cache_maintenance
739 }
740
741 #[must_use]
743 pub const fn total(self) -> Duration {
744 self.total
745 }
746}
747
748impl WasmInputResolutionTimings {
749 #[must_use]
751 pub const fn tool_identity(self) -> Duration {
752 self.tool_identity
753 }
754
755 #[must_use]
757 pub const fn cargo_metadata(self) -> Duration {
758 self.cargo_metadata
759 }
760
761 #[must_use]
763 pub const fn input_discovery(self) -> Duration {
764 self.input_discovery
765 }
766
767 #[must_use]
769 pub const fn content_hashing(self) -> Duration {
770 self.content_hashing
771 }
772
773 #[must_use]
775 pub const fn total(self) -> Duration {
776 self.total
777 }
778
779 const fn include(&mut self, other: Self) {
780 self.tool_identity = self.tool_identity.saturating_add(other.tool_identity);
781 self.cargo_metadata = self.cargo_metadata.saturating_add(other.cargo_metadata);
782 self.input_discovery = self.input_discovery.saturating_add(other.input_discovery);
783 self.content_hashing = self.content_hashing.saturating_add(other.content_hashing);
784 self.total = self.total.saturating_add(other.total);
785 }
786}
787
788impl CargoBuildInput {
789 #[must_use]
791 pub fn label(&self) -> &Path {
792 &self.label
793 }
794
795 #[must_use]
797 pub fn path(&self) -> &Path {
798 &self.path
799 }
800}
801
802impl ResolvedCargoBuildInputs {
803 #[must_use]
805 pub const fn fingerprint(&self) -> InputDigest {
806 self.fingerprint
807 }
808
809 #[must_use]
811 pub const fn input_digest(&self) -> InputDigest {
812 self.input_digest
813 }
814
815 #[must_use]
817 pub fn inputs(&self) -> &[CargoBuildInput] {
818 &self.inputs
819 }
820
821 #[must_use]
826 pub fn exclusions(&self) -> &[PathBuf] {
827 &self.exclusions
828 }
829
830 #[must_use]
832 pub const fn timings(&self) -> WasmInputResolutionTimings {
833 self.timings
834 }
835
836 pub fn is_current(&self, spec: &WasmBuildSpec) -> Result<bool, WasmBuildError> {
838 resolve_cargo_build_inputs(spec).map(|current| current.fingerprint == self.fingerprint)
839 }
840
841 pub fn is_content_current(&self) -> Result<bool, WasmBuildError> {
848 self.current_input_digest()
849 .map(|current| current == self.input_digest)
850 }
851
852 pub(super) fn current_input_digest(&self) -> Result<InputDigest, WasmBuildError> {
853 let inputs = self
854 .inputs
855 .iter()
856 .map(|input| (input.label.clone(), input.path.clone()))
857 .collect::<Vec<_>>();
858 digest_labeled_paths("wasm-source-inputs-v1", &inputs, &self.exclusions).map_err(|source| {
859 WasmBuildError::Io {
860 operation: "rehash resolved Cargo build inputs",
861 path: self
862 .inputs
863 .first()
864 .map_or_else(PathBuf::new, |input| input.path.clone()),
865 source,
866 }
867 })
868 }
869}
870
871impl SharedIncrementalTargetInspection {
872 #[must_use]
874 pub fn target_dir(&self) -> &Path {
875 &self.target_dir
876 }
877
878 #[must_use]
880 pub const fn logical_size_bytes(&self) -> u64 {
881 self.logical_size_bytes
882 }
883
884 #[must_use]
886 pub const fn last_used(&self) -> SystemTime {
887 self.last_used
888 }
889
890 #[must_use]
892 pub const fn lock_wait(&self) -> Duration {
893 self.lock_wait
894 }
895}
896
897impl SharedIncrementalTargetPrunePolicy {
898 #[must_use]
900 pub const fn new() -> Self {
901 Self {
902 max_age: None,
903 max_size_bytes: None,
904 }
905 }
906
907 #[must_use]
909 pub const fn with_max_age(mut self, max_age: Duration) -> Self {
910 self.max_age = Some(max_age);
911 self
912 }
913
914 #[must_use]
916 pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
917 self.max_size_bytes = Some(bytes);
918 self
919 }
920
921 #[must_use]
923 pub const fn max_age(self) -> Option<Duration> {
924 self.max_age
925 }
926
927 #[must_use]
929 pub const fn max_size_bytes(self) -> Option<u64> {
930 self.max_size_bytes
931 }
932
933 fn maintenance_identity(self) -> String {
934 format!(
935 "age={:?};size={:?}",
936 self.max_age.map(|duration| duration.as_nanos()),
937 self.max_size_bytes
938 )
939 }
940}
941
942impl SharedIncrementalTargetMaintenance {
943 #[must_use]
945 pub fn target_dir(&self) -> &Path {
946 &self.target_dir
947 }
948
949 #[must_use]
951 pub const fn logical_size_bytes_before(&self) -> u64 {
952 self.logical_size_bytes_before
953 }
954
955 #[must_use]
957 pub const fn logical_size_bytes_after(&self) -> u64 {
958 self.logical_size_bytes_after
959 }
960
961 #[must_use]
963 pub const fn last_used_before(&self) -> SystemTime {
964 self.last_used_before
965 }
966
967 #[must_use]
969 pub const fn was_cleared(&self) -> bool {
970 self.cleared
971 }
972
973 #[must_use]
975 pub const fn lock_wait(&self) -> Duration {
976 self.lock_wait
977 }
978
979 #[must_use]
981 pub const fn maintenance(&self) -> Duration {
982 self.maintenance
983 }
984}
985
986impl std::fmt::Display for SharedIncrementalTargetMaintenance {
987 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
988 write!(
989 formatter,
990 "target={} action={} bytes={}=>{} lock={:?} maintenance={:?}",
991 self.target_dir.display(),
992 if self.cleared { "cleared" } else { "retained" },
993 self.logical_size_bytes_before,
994 self.logical_size_bytes_after,
995 self.lock_wait,
996 self.maintenance,
997 )
998 }
999}
1000
1001impl SharedIncrementalTargetMaintenanceOutcome {
1002 #[must_use]
1004 pub fn target_dir(&self) -> &Path {
1005 match self {
1006 Self::Missing { target_dir } | Self::Skipped { target_dir, .. } => target_dir,
1007 Self::Performed { maintenance, .. } => maintenance.target_dir(),
1008 }
1009 }
1010
1011 #[must_use]
1013 pub const fn maintenance(&self) -> Option<&SharedIncrementalTargetMaintenance> {
1014 match self {
1015 Self::Performed { maintenance, .. } => Some(maintenance),
1016 Self::Missing { .. } | Self::Skipped { .. } => None,
1017 }
1018 }
1019
1020 #[must_use]
1022 pub const fn was_performed(&self) -> bool {
1023 matches!(self, Self::Performed { .. })
1024 }
1025
1026 #[must_use]
1028 pub const fn lock_wait(&self) -> Option<Duration> {
1029 match self {
1030 Self::Missing { .. } => None,
1031 Self::Skipped { lock_wait, .. } => Some(*lock_wait),
1032 Self::Performed { maintenance, .. } => Some(maintenance.lock_wait()),
1033 }
1034 }
1035
1036 #[must_use]
1038 pub const fn schedule_check(&self) -> Option<Duration> {
1039 match self {
1040 Self::Missing { .. } => None,
1041 Self::Skipped { schedule_check, .. } | Self::Performed { schedule_check, .. } => {
1042 Some(*schedule_check)
1043 }
1044 }
1045 }
1046}
1047
1048impl std::fmt::Display for SharedIncrementalTargetMaintenanceOutcome {
1049 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1050 match self {
1051 Self::Missing { target_dir } => {
1052 write!(formatter, "target={} action=missing", target_dir.display())
1053 }
1054 Self::Skipped {
1055 target_dir,
1056 lock_wait,
1057 schedule_check,
1058 } => write!(
1059 formatter,
1060 "target={} action=skipped lock={lock_wait:?} schedule={schedule_check:?}",
1061 target_dir.display(),
1062 ),
1063 Self::Performed {
1064 maintenance,
1065 schedule_check,
1066 } => write!(formatter, "{maintenance} schedule={schedule_check:?}"),
1067 }
1068 }
1069}
1070
1071impl std::fmt::Display for WasmBuildTimings {
1072 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1073 write!(
1074 formatter,
1075 "total={:?} lock={:?} shared_lock={:?} inputs={:?} cargo={:?} maintenance={:?}",
1076 self.total,
1077 self.lock_wait,
1078 self.shared_incremental_lock_wait,
1079 self.input_resolution.total,
1080 self.cargo_build,
1081 self.cache_maintenance,
1082 )
1083 }
1084}
1085
1086impl std::fmt::Display for WasmBuildOutcome {
1087 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1088 let state = if self.is_reused() { "reused" } else { "built" };
1089 write!(
1090 formatter,
1091 "{state} fingerprint={} artifacts={} {}",
1092 self.record().fingerprint,
1093 self.record().artifacts.len(),
1094 self.record().timings,
1095 )
1096 }
1097}
1098
1099pub fn resolve_cargo_build_inputs(
1104 spec: &WasmBuildSpec,
1105) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1106 validate_spec(spec)?;
1107 build_fingerprint(spec)
1108}
1109
1110pub fn inspect_shared_incremental_target(
1115 spec: &WasmBuildSpec,
1116) -> Result<Option<SharedIncrementalTargetInspection>, WasmBuildError> {
1117 if !shared_incremental_target_exists(spec, "inspect shared incremental Cargo target")? {
1118 return Ok(None);
1119 }
1120
1121 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
1122 let logical_size_bytes =
1123 directory_logical_size(&canonical).map_err(|source| WasmBuildError::Io {
1124 operation: "measure shared incremental Cargo target",
1125 path: canonical.clone(),
1126 source,
1127 })?;
1128 let last_used = cache_entry_last_used(&canonical).map_err(|source| WasmBuildError::Io {
1129 operation: "read shared incremental Cargo target use time",
1130 path: canonical.clone(),
1131 source,
1132 })?;
1133 Ok(Some(SharedIncrementalTargetInspection {
1134 target_dir: canonical,
1135 logical_size_bytes,
1136 last_used,
1137 lock_wait,
1138 }))
1139}
1140
1141pub fn maintain_shared_incremental_target(
1155 spec: &WasmBuildSpec,
1156 policy: SharedIncrementalTargetPrunePolicy,
1157) -> Result<Option<SharedIncrementalTargetMaintenance>, WasmBuildError> {
1158 if !shared_incremental_target_exists(
1159 spec,
1160 "inspect shared incremental Cargo target before maintenance",
1161 )? {
1162 return Ok(None);
1163 }
1164
1165 let _ = resolve_cargo_build_inputs(spec)?;
1169 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
1170 maintain_shared_incremental_target_locked(&canonical, policy, lock_wait).map(Some)
1171}
1172
1173pub fn maintain_shared_incremental_target_at_most_every(
1187 spec: &WasmBuildSpec,
1188 policy: SharedIncrementalTargetPrunePolicy,
1189 minimum_interval: Duration,
1190) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
1191 let target_dir =
1192 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
1193 message: "shared incremental target is not configured".to_owned(),
1194 })?;
1195 if !shared_incremental_target_exists(
1196 spec,
1197 "inspect shared incremental Cargo target before scheduled maintenance",
1198 )? {
1199 return Ok(SharedIncrementalTargetMaintenanceOutcome::Missing { target_dir });
1200 }
1201
1202 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
1203 let schedule_root = canonical.join(".ic-testkit");
1204 let maintenance_identity = policy.maintenance_identity();
1205 let schedule_started = Instant::now();
1206 let due = cache_maintenance_due(
1207 &schedule_root,
1208 Some(minimum_interval),
1209 &maintenance_identity,
1210 )
1211 .map_err(wasm_cache_fs_error)?;
1212 let schedule_check = schedule_started.elapsed();
1213 if !due {
1214 return Ok(SharedIncrementalTargetMaintenanceOutcome::Skipped {
1215 target_dir: canonical,
1216 lock_wait,
1217 schedule_check,
1218 });
1219 }
1220
1221 let _ = resolve_cargo_build_inputs(spec)?;
1224 let maintenance = maintain_shared_incremental_target_locked(&canonical, policy, lock_wait)?;
1225 record_cache_maintenance(&schedule_root, &maintenance_identity).map_err(wasm_cache_fs_error)?;
1226 Ok(SharedIncrementalTargetMaintenanceOutcome::Performed {
1227 maintenance,
1228 schedule_check,
1229 })
1230}
1231
1232fn maintain_shared_incremental_target_locked(
1233 canonical: &Path,
1234 policy: SharedIncrementalTargetPrunePolicy,
1235 lock_wait: Duration,
1236) -> Result<SharedIncrementalTargetMaintenance, WasmBuildError> {
1237 let started = Instant::now();
1238 let logical_size_bytes_before =
1239 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
1240 operation: "measure shared incremental Cargo target before maintenance",
1241 path: canonical.to_owned(),
1242 source,
1243 })?;
1244 let last_used_before =
1245 cache_entry_last_used(canonical).map_err(|source| WasmBuildError::Io {
1246 operation: "read shared incremental Cargo target use time before maintenance",
1247 path: canonical.to_owned(),
1248 source,
1249 })?;
1250 let expired = policy.max_age.is_some_and(|max_age| {
1251 SystemTime::now()
1252 .duration_since(last_used_before)
1253 .is_ok_and(|age| age > max_age)
1254 });
1255 let oversized = policy
1256 .max_size_bytes
1257 .is_some_and(|max_size_bytes| logical_size_bytes_before > max_size_bytes);
1258 let cleared = expired || oversized;
1259 if cleared {
1260 clear_shared_incremental_target_contents(canonical)?;
1261 record_cache_entry_use(canonical)?;
1262 }
1263 let logical_size_bytes_after = if cleared {
1264 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
1265 operation: "measure shared incremental Cargo target after maintenance",
1266 path: canonical.to_owned(),
1267 source,
1268 })?
1269 } else {
1270 logical_size_bytes_before
1271 };
1272 Ok(SharedIncrementalTargetMaintenance {
1273 target_dir: canonical.to_owned(),
1274 logical_size_bytes_before,
1275 logical_size_bytes_after,
1276 last_used_before,
1277 cleared,
1278 lock_wait,
1279 maintenance: started.elapsed(),
1280 })
1281}
1282
1283fn clear_shared_incremental_target_contents(target_dir: &Path) -> Result<(), WasmBuildError> {
1284 let entries = fs::read_dir(target_dir).map_err(|source| WasmBuildError::Io {
1285 operation: "read shared incremental Cargo target for maintenance",
1286 path: target_dir.to_owned(),
1287 source,
1288 })?;
1289 for entry in entries {
1290 let path = entry
1291 .map_err(|source| WasmBuildError::Io {
1292 operation: "read shared incremental Cargo target entry for maintenance",
1293 path: target_dir.to_owned(),
1294 source,
1295 })?
1296 .path();
1297 let preserved = path
1298 .file_name()
1299 .is_some_and(|name| name == ".ic-testkit" || name == "CACHEDIR.TAG");
1300 if !preserved {
1301 remove_path_if_present(&path).map_err(|source| WasmBuildError::Io {
1302 operation: "clear shared incremental Cargo target entry",
1303 path,
1304 source,
1305 })?;
1306 }
1307 }
1308 Ok(())
1309}
1310
1311pub fn build_wasm_canisters_cached(
1318 spec: &WasmBuildSpec,
1319) -> Result<WasmBuildOutcome, WasmBuildError> {
1320 build_wasm_canisters_cached_internal(spec, &mut ProgressReporter::silent())
1321}
1322
1323pub fn build_wasm_canisters_cached_with_progress<F>(
1331 spec: &WasmBuildSpec,
1332 config: WasmBuildProgressConfig,
1333 mut observer: F,
1334) -> Result<WasmBuildOutcome, WasmBuildError>
1335where
1336 F: FnMut(WasmBuildProgressEvent),
1337{
1338 if config.heartbeat_interval == Some(Duration::ZERO) {
1339 return Err(WasmBuildError::InvalidSpec {
1340 message: "Wasm build progress heartbeat interval must be greater than zero".to_owned(),
1341 });
1342 }
1343 build_wasm_canisters_cached_internal(
1344 spec,
1345 &mut ProgressReporter {
1346 config,
1347 observer: Some(&mut observer),
1348 },
1349 )
1350}
1351
1352fn build_wasm_canisters_cached_internal(
1353 spec: &WasmBuildSpec,
1354 progress: &mut ProgressReporter<'_>,
1355) -> Result<WasmBuildOutcome, WasmBuildError> {
1356 let total_started = Instant::now();
1357 validate_spec(spec)?;
1358 progress.emit(WasmBuildProgressEvent::Started);
1359 let (cache_lock, first_lock_wait) = lock_wasm_build_cache(&spec.target_dir)?;
1360 ensure_cache_directory_tag(&spec.target_dir)?;
1361
1362 let resolved = resolve_inputs_with_progress(spec, progress)?;
1363 if let Some(outcome) =
1364 try_reuse_wasm_artifacts(spec, &resolved, first_lock_wait, None, total_started)?
1365 {
1366 emit_finished_progress(&outcome, progress);
1367 return Ok(outcome);
1368 }
1369 progress.emit(WasmBuildProgressEvent::CacheMiss {
1370 fingerprint: resolved.fingerprint,
1371 });
1372
1373 let outcome = match &spec.cache_mode {
1374 WasmBuildCacheMode::Isolated => {
1375 let cache_entry = cache_entry_directory(spec, resolved.fingerprint);
1376 build_wasm_cache_miss(
1377 spec,
1378 resolved,
1379 first_lock_wait,
1380 None,
1381 cache_entry,
1382 total_started,
1383 progress,
1384 )
1385 }
1386 WasmBuildCacheMode::SharedIncremental { .. } => {
1387 drop(cache_lock);
1388 let configured_target = shared_incremental_target(spec)
1389 .expect("shared cache mode must resolve a shared Cargo target");
1390 progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted {
1391 target_dir: configured_target,
1392 });
1393 let (shared_lock, shared_lock_wait, shared_target) =
1394 lock_shared_incremental_target(spec)?;
1395 progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
1396 target_dir: shared_target.clone(),
1397 wait: shared_lock_wait,
1398 });
1399 let (_cache_lock, second_lock_wait) = lock_wasm_build_cache(&spec.target_dir)?;
1400 ensure_cache_directory_tag(&spec.target_dir)?;
1401
1402 let mut current = resolve_inputs_with_progress(spec, progress)?;
1403 current.timings.include(resolved.timings);
1404 let lock_wait = first_lock_wait.saturating_add(second_lock_wait);
1405 if let Some(outcome) = try_reuse_wasm_artifacts(
1406 spec,
1407 ¤t,
1408 lock_wait,
1409 Some(shared_lock_wait),
1410 total_started,
1411 )? {
1412 emit_finished_progress(&outcome, progress);
1413 return Ok(outcome);
1414 }
1415
1416 let outcome = build_wasm_cache_miss(
1417 spec,
1418 current,
1419 lock_wait,
1420 Some(shared_lock_wait),
1421 shared_target,
1422 total_started,
1423 progress,
1424 );
1425 drop(shared_lock);
1426 outcome
1427 }
1428 }?;
1429 emit_finished_progress(&outcome, progress);
1430 Ok(outcome)
1431}
1432
1433fn resolve_inputs_with_progress(
1434 spec: &WasmBuildSpec,
1435 progress: &mut ProgressReporter<'_>,
1436) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1437 let resolved = build_fingerprint(spec)?;
1438 progress.emit(WasmBuildProgressEvent::InputsResolved {
1439 fingerprint: resolved.fingerprint,
1440 input_digest: resolved.input_digest,
1441 elapsed: resolved.timings.total,
1442 });
1443 Ok(resolved)
1444}
1445
1446fn emit_finished_progress(outcome: &WasmBuildOutcome, progress: &mut ProgressReporter<'_>) {
1447 let state = if outcome.is_reused() {
1448 progress.emit(WasmBuildProgressEvent::CacheHit {
1449 fingerprint: outcome.record().fingerprint,
1450 });
1451 WasmBuildProgressOutcome::Reused
1452 } else {
1453 WasmBuildProgressOutcome::Built
1454 };
1455 progress.emit(WasmBuildProgressEvent::Finished {
1456 outcome: state,
1457 fingerprint: outcome.record().fingerprint,
1458 elapsed: outcome.record().timings.total,
1459 });
1460}
1461
1462fn try_reuse_wasm_artifacts(
1463 spec: &WasmBuildSpec,
1464 resolved: &ResolvedCargoBuildInputs,
1465 lock_wait: Duration,
1466 shared_incremental_lock_wait: Option<Duration>,
1467 total_started: Instant,
1468) -> Result<Option<WasmBuildOutcome>, WasmBuildError> {
1469 let fingerprint = resolved.fingerprint;
1470 let artifacts = expected_artifacts(spec, &spec.target_dir);
1471 let cache_entry = cache_entry_directory(spec, fingerprint);
1472 if artifact_set_matches(&artifacts, fingerprint) {
1473 record_cache_entry_use_if_present(&cache_entry)?;
1474 return Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
1475 spec,
1476 BuildRecordInput {
1477 fingerprint,
1478 input_digest: resolved.input_digest,
1479 artifacts,
1480 lock_wait,
1481 shared_incremental_lock_wait,
1482 input_resolution: resolved.timings,
1483 cargo_build: None,
1484 active_entry: &cache_entry,
1485 },
1486 total_started,
1487 ))));
1488 }
1489
1490 let cached_artifacts = expected_artifacts(spec, &cache_entry);
1491 if !artifact_set_matches(&cached_artifacts, fingerprint) {
1492 return Ok(None);
1493 }
1494 materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
1495 record_cache_entry_use(&cache_entry)?;
1496 Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
1497 spec,
1498 BuildRecordInput {
1499 fingerprint,
1500 input_digest: resolved.input_digest,
1501 artifacts,
1502 lock_wait,
1503 shared_incremental_lock_wait,
1504 input_resolution: resolved.timings,
1505 cargo_build: None,
1506 active_entry: &cache_entry,
1507 },
1508 total_started,
1509 ))))
1510}
1511
1512fn build_wasm_cache_miss(
1513 spec: &WasmBuildSpec,
1514 resolved: ResolvedCargoBuildInputs,
1515 lock_wait: Duration,
1516 shared_incremental_lock_wait: Option<Duration>,
1517 cargo_target_dir: PathBuf,
1518 total_started: Instant,
1519 progress: &mut ProgressReporter<'_>,
1520) -> Result<WasmBuildOutcome, WasmBuildError> {
1521 let fingerprint = resolved.fingerprint;
1522 let mut input_resolution = resolved.timings;
1523 let artifacts = expected_artifacts(spec, &spec.target_dir);
1524 let cache_entry = cache_entry_directory(spec, fingerprint);
1525 remove_directory_if_present(&cache_entry)?;
1526 create_dir_all(
1527 &cache_entry,
1528 "create content-addressed Cargo target directory",
1529 )?;
1530 let incomplete_directory = IncompleteBuildDirectory::new(cache_entry.clone());
1531 let build_result = (|| {
1532 if matches!(
1533 spec.cache_mode,
1534 WasmBuildCacheMode::SharedIncremental { .. }
1535 ) {
1536 record_cache_entry_use(&cargo_target_dir)?;
1537 }
1538 let build_started = Instant::now();
1539 run_cargo_build(spec, &cargo_target_dir, progress)?;
1540 let cargo_build = build_started.elapsed();
1541 let built_artifacts = expected_artifacts(spec, &cargo_target_dir);
1542 let missing = missing_artifacts(&built_artifacts);
1543 if !missing.is_empty() {
1544 return Err(WasmBuildError::MissingArtifacts { paths: missing });
1545 }
1546
1547 let verified = resolve_inputs_with_progress(spec, progress)?;
1548 input_resolution.include(verified.timings);
1549 if fingerprint != verified.fingerprint {
1550 return Err(WasmBuildError::InputsChangedDuringBuild {
1551 before: fingerprint,
1552 after: verified.fingerprint,
1553 });
1554 }
1555
1556 let cached_artifacts = expected_artifacts(spec, &cache_entry);
1557 if cargo_target_dir != cache_entry {
1558 copy_wasm_artifacts(&built_artifacts, &cached_artifacts)?;
1559 }
1560 publish_artifact_stamps(&cached_artifacts, fingerprint)?;
1561 materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
1562 record_cache_entry_use(&cache_entry)?;
1563
1564 Ok(WasmBuildOutcome::Built(complete_build_record(
1565 spec,
1566 BuildRecordInput {
1567 fingerprint,
1568 input_digest: resolved.input_digest,
1569 artifacts,
1570 lock_wait,
1571 shared_incremental_lock_wait,
1572 input_resolution,
1573 cargo_build: Some(cargo_build),
1574 active_entry: &cache_entry,
1575 },
1576 total_started,
1577 )))
1578 })();
1579 finish_fingerprint_build(build_result, incomplete_directory)
1580}
1581
1582pub fn prune_wasm_build_cache(
1590 target_dir: &Path,
1591 policy: WasmBuildCachePrunePolicy,
1592) -> Result<WasmBuildCachePruneReport, WasmBuildError> {
1593 let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
1594 ensure_cache_directory_tag(target_dir)?;
1595
1596 prune_wasm_build_cache_locked(target_dir, policy, None)
1597}
1598
1599struct BuildRecordInput<'a> {
1600 fingerprint: InputDigest,
1601 input_digest: InputDigest,
1602 artifacts: Vec<PathBuf>,
1603 lock_wait: Duration,
1604 shared_incremental_lock_wait: Option<Duration>,
1605 input_resolution: WasmInputResolutionTimings,
1606 cargo_build: Option<Duration>,
1607 active_entry: &'a Path,
1608}
1609
1610fn complete_build_record(
1611 spec: &WasmBuildSpec,
1612 input: BuildRecordInput<'_>,
1613 total_started: Instant,
1614) -> WasmBuildRecord {
1615 let (maintenance, cache_maintenance) = spec.prune_policy.map_or((None, None), |policy| {
1616 let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
1617 let identity = policy.maintenance_identity();
1618 perform_scheduled_cache_maintenance(&cache_root, spec.prune_interval, &identity, || {
1619 prune_wasm_build_cache_locked(&spec.target_dir, policy, Some(input.active_entry))
1620 .map_err(|error| error.to_string())
1621 })
1622 });
1623 WasmBuildRecord {
1624 fingerprint: input.fingerprint,
1625 input_digest: input.input_digest,
1626 artifacts: input.artifacts,
1627 timings: WasmBuildTimings {
1628 lock_wait: input.lock_wait,
1629 shared_incremental_lock_wait: input.shared_incremental_lock_wait,
1630 input_resolution: input.input_resolution,
1631 cargo_build: input.cargo_build,
1632 cache_maintenance,
1633 total: total_started.elapsed(),
1634 },
1635 maintenance,
1636 }
1637}
1638
1639fn prune_wasm_build_cache_locked(
1640 target_dir: &Path,
1641 policy: WasmBuildCachePrunePolicy,
1642 protected_entry: Option<&Path>,
1643) -> Result<WasmBuildCachePruneReport, WasmBuildError> {
1644 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
1645 prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
1646 .map_err(wasm_cache_fs_error)
1647}
1648
1649struct IncompleteBuildDirectory {
1650 path: PathBuf,
1651 armed: bool,
1652}
1653
1654impl IncompleteBuildDirectory {
1655 const fn new(path: PathBuf) -> Self {
1656 Self { path, armed: true }
1657 }
1658
1659 fn preserve(mut self) {
1660 self.armed = false;
1661 }
1662
1663 fn cleanup(mut self) -> io::Result<()> {
1664 let result = remove_path_if_present(&self.path);
1665 if result.is_ok() {
1666 self.armed = false;
1667 }
1668 result
1669 }
1670}
1671
1672impl Drop for IncompleteBuildDirectory {
1673 fn drop(&mut self) {
1674 if self.armed {
1675 let _ = remove_path_if_present(&self.path);
1676 }
1677 }
1678}
1679
1680fn finish_fingerprint_build(
1681 result: Result<WasmBuildOutcome, WasmBuildError>,
1682 incomplete_directory: IncompleteBuildDirectory,
1683) -> Result<WasmBuildOutcome, WasmBuildError> {
1684 match result {
1685 Ok(outcome) => {
1686 incomplete_directory.preserve();
1687 Ok(outcome)
1688 }
1689 Err(build_error) => {
1690 let path = incomplete_directory.path.clone();
1691 match incomplete_directory.cleanup() {
1692 Ok(()) => Err(build_error),
1693 Err(source) => Err(WasmBuildError::FailedBuildCleanup {
1694 build_error: Box::new(build_error),
1695 path,
1696 source,
1697 }),
1698 }
1699 }
1700 }
1701}
1702
1703fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
1704 create_dir_all(target_dir, "create Cargo target directory")?;
1705 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
1706 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)
1707}
1708
1709fn lock_shared_incremental_target(
1710 spec: &WasmBuildSpec,
1711) -> Result<(File, Duration, PathBuf), WasmBuildError> {
1712 let target_dir =
1713 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
1714 message: "shared incremental target is not configured".to_owned(),
1715 })?;
1716 create_dir_all(
1717 &target_dir,
1718 "create shared incremental Cargo target directory",
1719 )?;
1720 ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
1721 let canonical = target_dir
1722 .canonicalize()
1723 .map_err(|source| WasmBuildError::Io {
1724 operation: "resolve shared incremental Cargo target directory",
1725 path: target_dir.clone(),
1726 source,
1727 })?;
1728 let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
1729 let (lock, wait) = lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)?;
1730 Ok((lock, wait, canonical))
1731}
1732
1733fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
1734 ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
1735}
1736
1737fn record_cache_entry_use_if_present(path: &Path) -> Result<(), WasmBuildError> {
1738 if path.is_dir() {
1739 record_cache_entry_use(path)?;
1740 }
1741 Ok(())
1742}
1743
1744fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
1745 record_entry_use(path).map_err(wasm_cache_fs_error)
1746}
1747
1748fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
1749 WasmBuildError::Io {
1750 operation: error.operation,
1751 path: error.path,
1752 source: error.source,
1753 }
1754}
1755
1756fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
1757 if spec.packages.is_empty() {
1758 return Err(WasmBuildError::InvalidSpec {
1759 message: "at least one Cargo package is required".to_owned(),
1760 });
1761 }
1762 if spec.profile_target_dir.is_empty() {
1763 return Err(WasmBuildError::InvalidSpec {
1764 message: "Cargo profile target directory must not be empty".to_owned(),
1765 });
1766 }
1767 if spec.target.is_empty() {
1768 return Err(WasmBuildError::InvalidSpec {
1769 message: "Cargo compilation target must not be empty".to_owned(),
1770 });
1771 }
1772 if matches!(
1773 &spec.cache_mode,
1774 WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
1775 ) {
1776 return Err(WasmBuildError::InvalidSpec {
1777 message: "shared incremental Cargo target directory must not be empty".to_owned(),
1778 });
1779 }
1780 Ok(())
1781}
1782
1783fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1784 let total_started = Instant::now();
1785 let tool_started = Instant::now();
1786 let cargo_identity = command_identity(
1787 spec,
1788 WasmBuildPhase::CargoIdentity,
1789 &spec.cargo_program,
1790 &["--version", "--verbose"],
1791 )?;
1792 let rustc_program = spec
1793 .extra_env
1794 .get(OsStr::new("RUSTC"))
1795 .unwrap_or(&spec.rustc_program);
1796 let rustc_identity =
1797 command_identity(spec, WasmBuildPhase::RustcIdentity, rustc_program, &["-vV"])?;
1798 let tool_identity = tool_started.elapsed();
1799
1800 let metadata_started = Instant::now();
1801 let metadata = cargo_metadata(spec)?;
1802 let cargo_metadata = metadata_started.elapsed();
1803
1804 let discovery_started = Instant::now();
1805 let inputs = resolve_local_inputs(spec, &metadata)?;
1806 validate_shared_incremental_target_boundary(spec, &inputs)?;
1807 let exclusions = source_exclusions(spec, &inputs);
1808 let input_discovery = discovery_started.elapsed();
1809
1810 let hashing_started = Instant::now();
1811 let input_digest = digest_labeled_paths("wasm-source-inputs-v1", &inputs, &exclusions)
1812 .map_err(|source| WasmBuildError::Io {
1813 operation: "hash Wasm build inputs",
1814 path: spec.workspace_root.clone(),
1815 source,
1816 })?;
1817 let content_hashing = hashing_started.elapsed();
1818
1819 let mut hasher = InputHasher::new(CACHE_FORMAT_VERSION);
1820 let mut packages = spec.packages.clone();
1821 packages.sort();
1822 packages.dedup();
1823 for package in packages {
1824 hasher.field("package", package.as_bytes());
1825 }
1826 hasher.field("target", spec.target.as_bytes());
1827 hasher.field("profile-target-dir", spec.profile_target_dir.as_bytes());
1828 for argument in &spec.cargo_profile_args {
1829 hasher.field("cargo-argument", &os_bytes(argument));
1830 }
1831 for (key, value) in effective_environment(spec) {
1832 hasher.field("environment-key", &os_bytes(&key));
1833 if let Some(value) = value {
1834 hasher.field("environment-value", &os_bytes(&value));
1835 } else {
1836 hasher.field("environment-unset", b"");
1837 }
1838 }
1839 hasher.field("cargo-identity", &cargo_identity);
1840 hasher.field("rustc-identity", &rustc_identity);
1841 hasher.field("source-input-digest", input_digest.as_bytes());
1842 Ok(ResolvedCargoBuildInputs {
1843 fingerprint: hasher.finish(),
1844 input_digest,
1845 inputs: inputs
1846 .into_iter()
1847 .map(|(label, path)| CargoBuildInput { label, path })
1848 .collect(),
1849 exclusions,
1850 timings: WasmInputResolutionTimings {
1851 tool_identity,
1852 cargo_metadata,
1853 input_discovery,
1854 content_hashing,
1855 total: total_started.elapsed(),
1856 },
1857 })
1858}
1859
1860fn command_identity(
1861 spec: &WasmBuildSpec,
1862 phase: WasmBuildPhase,
1863 program: &OsStr,
1864 arguments: &[&str],
1865) -> Result<Vec<u8>, WasmBuildError> {
1866 let mut command = Command::new(program);
1867 command.current_dir(&spec.workspace_root).args(arguments);
1868 apply_command_environment(&mut command, spec);
1869 let output = command
1870 .output()
1871 .map_err(|source| WasmBuildError::CommandSpawn {
1872 phase,
1873 program: program.to_owned(),
1874 source,
1875 })?;
1876 ensure_command_success(phase, output).map(|output| {
1877 let mut identity = output.stdout;
1878 identity.extend_from_slice(&output.stderr);
1879 identity
1880 })
1881}
1882
1883fn cargo_metadata(spec: &WasmBuildSpec) -> Result<Value, WasmBuildError> {
1884 let mut command = Command::new(&spec.cargo_program);
1885 command
1886 .current_dir(&spec.workspace_root)
1887 .args(["metadata", "--format-version", "1"]);
1888 for argument in metadata_arguments(&spec.cargo_profile_args) {
1889 command.arg(argument);
1890 }
1891 apply_command_environment(&mut command, spec);
1892 let output = command
1893 .output()
1894 .map_err(|source| WasmBuildError::CommandSpawn {
1895 phase: WasmBuildPhase::CargoMetadata,
1896 program: spec.cargo_program.clone(),
1897 source,
1898 })?;
1899 let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
1900 serde_json::from_slice(&output.stdout).map_err(|error| WasmBuildError::InvalidMetadata {
1901 message: format!("Cargo metadata was not valid JSON: {error}"),
1902 })
1903}
1904
1905fn metadata_arguments(arguments: &[OsString]) -> Vec<OsString> {
1906 let mut selected = Vec::new();
1907 let mut arguments = arguments.iter();
1908 while let Some(argument) = arguments.next() {
1909 let argument_text = argument.to_string_lossy();
1910 match argument_text.as_ref() {
1911 "--all-features" | "--no-default-features" | "--locked" | "--offline" | "--frozen" => {
1912 selected.push(argument.clone());
1913 }
1914 "--features" | "-F" | "--filter-platform" => {
1915 selected.push(argument.clone());
1916 if let Some(value) = arguments.next() {
1917 selected.push(value.clone());
1918 }
1919 }
1920 _ if argument_text.starts_with("--features=")
1921 || argument_text.starts_with("--filter-platform=") =>
1922 {
1923 selected.push(argument.clone());
1924 }
1925 _ => {}
1926 }
1927 }
1928 selected
1929}
1930
1931#[derive(Clone)]
1932struct MetadataPackage {
1933 id: String,
1934 name: String,
1935 version: String,
1936 manifest_path: PathBuf,
1937 is_local: bool,
1938}
1939
1940fn resolve_local_inputs(
1941 spec: &WasmBuildSpec,
1942 metadata: &Value,
1943) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
1944 let packages = metadata_packages(metadata)?;
1945 let mut selected_ids = selected_package_ids(spec, metadata, &packages)?;
1946 let dependencies = metadata_dependencies(metadata)?;
1947 let mut closure = BTreeSet::new();
1948 while let Some(id) = selected_ids.pop_front() {
1949 if !closure.insert(id.clone()) {
1950 continue;
1951 }
1952 if let Some(deps) = dependencies.get(&id) {
1953 selected_ids.extend(deps.iter().cloned());
1954 }
1955 }
1956
1957 let workspace_root = metadata
1958 .get("workspace_root")
1959 .and_then(Value::as_str)
1960 .map_or_else(|| spec.workspace_root.clone(), PathBuf::from);
1961 let mut inputs = workspace_configuration_inputs(spec, &workspace_root)?;
1962 append_package_inputs(&mut inputs, &packages, closure, &workspace_root)?;
1963 append_additional_inputs(&mut inputs, spec, &workspace_root);
1964 Ok(inputs)
1965}
1966
1967fn metadata_packages(metadata: &Value) -> Result<HashMap<String, MetadataPackage>, WasmBuildError> {
1968 let packages_value = metadata
1969 .get("packages")
1970 .and_then(Value::as_array)
1971 .ok_or_else(|| invalid_metadata("Cargo metadata has no package array"))?;
1972 let mut packages = HashMap::new();
1973 for value in packages_value {
1974 let package = MetadataPackage {
1975 id: required_string(value, "id")?,
1976 name: required_string(value, "name")?,
1977 version: required_string(value, "version")?,
1978 manifest_path: PathBuf::from(required_string(value, "manifest_path")?),
1979 is_local: value.get("source").is_some_and(Value::is_null),
1980 };
1981 packages.insert(package.id.clone(), package);
1982 }
1983 Ok(packages)
1984}
1985
1986fn selected_package_ids(
1987 spec: &WasmBuildSpec,
1988 metadata: &Value,
1989 packages: &HashMap<String, MetadataPackage>,
1990) -> Result<VecDeque<String>, WasmBuildError> {
1991 let workspace_members = metadata
1992 .get("workspace_members")
1993 .and_then(Value::as_array)
1994 .ok_or_else(|| invalid_metadata("Cargo metadata has no workspace member array"))?
1995 .iter()
1996 .filter_map(Value::as_str)
1997 .collect::<HashSet<_>>();
1998 let mut selected_ids = VecDeque::new();
1999 for requested in &spec.packages {
2000 let matches = packages
2001 .values()
2002 .filter(|package| {
2003 package.name == *requested && workspace_members.contains(package.id.as_str())
2004 })
2005 .map(|package| package.id.clone())
2006 .collect::<Vec<_>>();
2007 match matches.as_slice() {
2008 [id] => selected_ids.push_back(id.clone()),
2009 [] => {
2010 return Err(WasmBuildError::InvalidSpec {
2011 message: format!("Cargo workspace contains no package named `{requested}`"),
2012 });
2013 }
2014 _ => {
2015 return Err(WasmBuildError::InvalidSpec {
2016 message: format!("Cargo workspace package name `{requested}` is ambiguous"),
2017 });
2018 }
2019 }
2020 }
2021 Ok(selected_ids)
2022}
2023
2024fn metadata_dependencies(metadata: &Value) -> Result<HashMap<String, Vec<String>>, WasmBuildError> {
2025 let mut dependencies = HashMap::<String, Vec<String>>::new();
2026 let nodes = metadata
2027 .pointer("/resolve/nodes")
2028 .and_then(Value::as_array)
2029 .ok_or_else(|| invalid_metadata("Cargo metadata has no resolved dependency nodes"))?;
2030 for node in nodes {
2031 let id = required_string(node, "id")?;
2032 let deps = node
2033 .get("deps")
2034 .and_then(Value::as_array)
2035 .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
2036 .iter()
2037 .map(|dependency| required_string(dependency, "pkg"))
2038 .collect::<Result<Vec<_>, _>>()?;
2039 dependencies.insert(id, deps);
2040 }
2041 Ok(dependencies)
2042}
2043
2044fn workspace_configuration_inputs(
2045 spec: &WasmBuildSpec,
2046 workspace_root: &Path,
2047) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
2048 let mut inputs = Vec::new();
2049 add_if_present(
2050 &mut inputs,
2051 "workspace/Cargo.toml",
2052 workspace_root.join("Cargo.toml"),
2053 );
2054 add_if_present(
2055 &mut inputs,
2056 "workspace/Cargo.lock",
2057 workspace_root.join("Cargo.lock"),
2058 );
2059 add_if_present(
2060 &mut inputs,
2061 "workspace/rust-toolchain.toml",
2062 workspace_root.join("rust-toolchain.toml"),
2063 );
2064 add_if_present(
2065 &mut inputs,
2066 "workspace/rust-toolchain",
2067 workspace_root.join("rust-toolchain"),
2068 );
2069 append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
2070 Ok(inputs)
2071}
2072
2073fn append_cargo_configuration_inputs(
2074 inputs: &mut Vec<(PathBuf, PathBuf)>,
2075 spec: &WasmBuildSpec,
2076 workspace_root: &Path,
2077) -> Result<(), WasmBuildError> {
2078 let invocation_root =
2079 spec.workspace_root
2080 .canonicalize()
2081 .map_err(|source| WasmBuildError::Io {
2082 operation: "resolve Cargo invocation directory",
2083 path: spec.workspace_root.clone(),
2084 source,
2085 })?;
2086 let canonical_workspace =
2087 workspace_root
2088 .canonicalize()
2089 .map_err(|source| WasmBuildError::Io {
2090 operation: "resolve Cargo workspace directory",
2091 path: workspace_root.to_owned(),
2092 source,
2093 })?;
2094
2095 let mut roots = invocation_root
2096 .ancestors()
2097 .filter_map(|directory| effective_cargo_config(&directory.join(".cargo")))
2098 .collect::<Vec<_>>();
2099 if let Some(cargo_home) = effective_cargo_home(spec, &invocation_root)
2100 && let Some(config) = effective_cargo_config(&cargo_home)
2101 {
2102 roots.push(config);
2103 }
2104
2105 let mut visited = BTreeSet::new();
2106 for config in roots {
2107 append_cargo_configuration_tree(
2108 inputs,
2109 &config,
2110 &canonical_workspace,
2111 &mut visited,
2112 false,
2113 )?;
2114 }
2115 Ok(())
2116}
2117
2118fn effective_cargo_config(directory: &Path) -> Option<PathBuf> {
2119 let extensionless = directory.join("config");
2120 if extensionless.exists() {
2121 return Some(extensionless);
2122 }
2123 let toml = directory.join("config.toml");
2124 toml.exists().then_some(toml)
2125}
2126
2127fn effective_cargo_home(spec: &WasmBuildSpec, invocation_root: &Path) -> Option<PathBuf> {
2128 if let Some(cargo_home) = command_environment_value(spec, "CARGO_HOME") {
2129 let cargo_home = PathBuf::from(cargo_home);
2130 return Some(if cargo_home.is_absolute() {
2131 cargo_home
2132 } else {
2133 invocation_root.join(cargo_home)
2134 });
2135 }
2136
2137 default_home_directory(spec).map(|home| {
2138 let home = if home.is_absolute() {
2139 home
2140 } else {
2141 invocation_root.join(home)
2142 };
2143 home.join(".cargo")
2144 })
2145}
2146
2147#[cfg(windows)]
2148fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
2149 command_environment_value(spec, "USERPROFILE")
2150 .or_else(|| command_environment_value(spec, "HOME"))
2151 .map(PathBuf::from)
2152}
2153
2154#[cfg(not(windows))]
2155fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
2156 command_environment_value(spec, "HOME").map(PathBuf::from)
2157}
2158
2159fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
2160 spec.extra_env
2161 .get(OsStr::new(name))
2162 .cloned()
2163 .or_else(|| std::env::var_os(name))
2164}
2165
2166fn append_cargo_configuration_tree(
2167 inputs: &mut Vec<(PathBuf, PathBuf)>,
2168 config: &Path,
2169 workspace_root: &Path,
2170 visited: &mut BTreeSet<PathBuf>,
2171 optional: bool,
2172) -> Result<(), WasmBuildError> {
2173 let canonical = match config.canonicalize() {
2174 Ok(canonical) => canonical,
2175 Err(error) if optional && error.kind() == io::ErrorKind::NotFound => return Ok(()),
2176 Err(source) => {
2177 return Err(WasmBuildError::Io {
2178 operation: "resolve Cargo configuration",
2179 path: config.to_owned(),
2180 source,
2181 });
2182 }
2183 };
2184 if !visited.insert(canonical.clone()) {
2185 return Ok(());
2186 }
2187
2188 let contents = fs::read_to_string(&canonical).map_err(|source| WasmBuildError::Io {
2189 operation: "read Cargo configuration",
2190 path: canonical.clone(),
2191 source,
2192 })?;
2193 let configuration = toml::from_str::<TomlValue>(&contents).map_err(|error| {
2194 WasmBuildError::InvalidCargoConfiguration {
2195 path: canonical.clone(),
2196 message: error.to_string(),
2197 }
2198 })?;
2199 inputs.push((
2200 cargo_configuration_label(&canonical, workspace_root),
2201 canonical.clone(),
2202 ));
2203
2204 let Some(include) = configuration.get("include") else {
2205 return Ok(());
2206 };
2207 let parent = canonical
2208 .parent()
2209 .ok_or_else(|| WasmBuildError::InvalidCargoConfiguration {
2210 path: canonical.clone(),
2211 message: "configuration path has no parent directory".to_owned(),
2212 })?;
2213 for (included, optional) in cargo_configuration_includes(include, &canonical)? {
2214 let included = if included.is_absolute() {
2215 included
2216 } else {
2217 parent.join(included)
2218 };
2219 append_cargo_configuration_tree(inputs, &included, workspace_root, visited, optional)?;
2220 }
2221 Ok(())
2222}
2223
2224fn cargo_configuration_includes(
2225 include: &TomlValue,
2226 config: &Path,
2227) -> Result<Vec<(PathBuf, bool)>, WasmBuildError> {
2228 let values = match include {
2229 TomlValue::Array(values) => values.as_slice(),
2230 value => std::slice::from_ref(value),
2231 };
2232 values
2233 .iter()
2234 .map(|value| match value {
2235 TomlValue::String(path) => Ok((PathBuf::from(path), false)),
2236 TomlValue::Table(table) => {
2237 let path = table
2238 .get("path")
2239 .and_then(TomlValue::as_str)
2240 .ok_or_else(|| {
2241 invalid_cargo_configuration(
2242 config,
2243 "Cargo configuration include table requires a string `path`",
2244 )
2245 })?;
2246 let optional = table
2247 .get("optional")
2248 .map(|value| {
2249 value.as_bool().ok_or_else(|| {
2250 invalid_cargo_configuration(
2251 config,
2252 "Cargo configuration include `optional` must be a boolean",
2253 )
2254 })
2255 })
2256 .transpose()?
2257 .unwrap_or(false);
2258 Ok((PathBuf::from(path), optional))
2259 }
2260 _ => Err(invalid_cargo_configuration(
2261 config,
2262 "Cargo configuration `include` must contain paths or include tables",
2263 )),
2264 })
2265 .collect()
2266}
2267
2268fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
2269 if let Ok(relative) = config.strip_prefix(workspace_root) {
2270 return PathBuf::from("cargo-config/workspace").join(relative);
2271 }
2272 let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
2273 PathBuf::from("cargo-config/external").join(location.to_hex())
2274}
2275
2276fn invalid_cargo_configuration(path: &Path, message: &str) -> WasmBuildError {
2277 WasmBuildError::InvalidCargoConfiguration {
2278 path: path.to_owned(),
2279 message: message.to_owned(),
2280 }
2281}
2282
2283fn append_package_inputs(
2284 inputs: &mut Vec<(PathBuf, PathBuf)>,
2285 packages: &HashMap<String, MetadataPackage>,
2286 closure: BTreeSet<String>,
2287 workspace_root: &Path,
2288) -> Result<(), WasmBuildError> {
2289 for id in closure {
2290 let Some(package) = packages.get(&id) else {
2291 return Err(invalid_metadata(&format!(
2292 "resolved package `{id}` is missing"
2293 )));
2294 };
2295 if !package.is_local {
2296 continue;
2297 }
2298 let root = package.manifest_path.parent().ok_or_else(|| {
2299 invalid_metadata(&format!(
2300 "package `{}` manifest has no parent",
2301 package.name
2302 ))
2303 })?;
2304 let relative_manifest = package
2305 .manifest_path
2306 .strip_prefix(workspace_root)
2307 .unwrap_or(&package.manifest_path);
2308 let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
2309 .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
2310 inputs.push((label, root.to_owned()));
2311 }
2312 Ok(())
2313}
2314
2315fn append_additional_inputs(
2316 inputs: &mut Vec<(PathBuf, PathBuf)>,
2317 spec: &WasmBuildSpec,
2318 workspace_root: &Path,
2319) {
2320 for additional in &spec.additional_inputs {
2321 let path = if additional.is_absolute() {
2322 additional.clone()
2323 } else {
2324 workspace_root.join(additional)
2325 };
2326 inputs.push((PathBuf::from("additional").join(additional), path));
2327 }
2328}
2329
2330fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
2331 let mut exclusions = vec![
2332 spec.target_dir.clone(),
2333 spec.workspace_root.join("target"),
2334 spec.workspace_root.join(".git"),
2335 ];
2336 if let Some(shared_target) = shared_incremental_target(spec) {
2337 exclusions.push(shared_target);
2338 }
2339 for (_, path) in inputs {
2340 if path.is_dir() {
2341 exclusions.push(path.join("target"));
2342 exclusions.push(path.join(".git"));
2343 }
2344 }
2345 exclusions
2346}
2347
2348fn validate_shared_incremental_target_boundary(
2349 spec: &WasmBuildSpec,
2350 inputs: &[(PathBuf, PathBuf)],
2351) -> Result<(), WasmBuildError> {
2352 let Some(shared_target) = shared_incremental_target(spec) else {
2353 return Ok(());
2354 };
2355 let shared_target =
2356 canonicalize_allow_missing(&shared_target).map_err(|source| WasmBuildError::Io {
2357 operation: "resolve shared incremental Cargo target boundary",
2358 path: shared_target.clone(),
2359 source,
2360 })?;
2361 let resolved_inputs = inputs
2362 .iter()
2363 .map(|(_, input)| {
2364 let canonical = input.canonicalize().map_err(|source| WasmBuildError::Io {
2365 operation: "resolve Cargo input boundary",
2366 path: input.clone(),
2367 source,
2368 })?;
2369 let metadata = fs::metadata(&canonical).map_err(|source| WasmBuildError::Io {
2370 operation: "inspect Cargo input boundary",
2371 path: canonical.clone(),
2372 source,
2373 })?;
2374 Ok((canonical, metadata.is_dir()))
2375 })
2376 .collect::<Result<Vec<_>, WasmBuildError>>()?;
2377 let safe_generated_roots = std::iter::once(spec.target_dir.clone())
2378 .chain(std::iter::once(spec.workspace_root.join("target")))
2379 .chain(
2380 inputs
2381 .iter()
2382 .filter(|(_, path)| path.is_dir())
2383 .map(|(_, path)| path.join("target")),
2384 )
2385 .filter_map(|path| canonicalize_allow_missing(&path).ok())
2386 .filter(|root| {
2387 !resolved_inputs
2388 .iter()
2389 .any(|(input, _is_directory)| input.starts_with(root))
2390 })
2391 .collect::<Vec<_>>();
2392 if safe_generated_roots
2393 .iter()
2394 .any(|root| shared_target.starts_with(root))
2395 {
2396 return Ok(());
2397 }
2398
2399 for (input, is_directory) in resolved_inputs {
2400 if shared_target == input
2401 || (is_directory && shared_target.starts_with(&input))
2402 || input.starts_with(&shared_target)
2403 {
2404 return Err(WasmBuildError::InvalidSpec {
2405 message: format!(
2406 "shared incremental target {} must not overlap exact Cargo inputs unless it is inside a generated target directory",
2407 shared_target.display()
2408 ),
2409 });
2410 }
2411 }
2412 Ok(())
2413}
2414
2415fn canonicalize_allow_missing(path: &Path) -> io::Result<PathBuf> {
2416 let absolute = if path.is_absolute() {
2417 path.to_owned()
2418 } else {
2419 std::env::current_dir()?.join(path)
2420 };
2421 let mut unresolved = Vec::<OsString>::new();
2422 let mut existing = absolute.as_path();
2423 loop {
2424 match existing.canonicalize() {
2425 Ok(mut canonical) => {
2426 for component in unresolved.into_iter().rev() {
2427 canonical.push(component);
2428 }
2429 return Ok(canonical);
2430 }
2431 Err(error) if error.kind() == io::ErrorKind::NotFound => {
2432 let Some(name) = existing.file_name() else {
2433 return Err(error);
2434 };
2435 unresolved.push(name.to_owned());
2436 existing = existing.parent().ok_or(error)?;
2437 }
2438 Err(error) => return Err(error),
2439 }
2440 }
2441}
2442
2443fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
2444 let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
2445 return None;
2446 };
2447 Some(if target_dir.is_absolute() {
2448 target_dir.clone()
2449 } else {
2450 spec.workspace_root.join(target_dir)
2451 })
2452}
2453
2454fn shared_incremental_target_exists(
2455 spec: &WasmBuildSpec,
2456 operation: &'static str,
2457) -> Result<bool, WasmBuildError> {
2458 let target_dir =
2459 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
2460 message: "shared incremental target is not configured".to_owned(),
2461 })?;
2462 match fs::symlink_metadata(&target_dir) {
2463 Ok(metadata) if metadata.is_dir() => Ok(true),
2464 Ok(_) => Err(WasmBuildError::InvalidSpec {
2465 message: format!(
2466 "shared incremental Cargo target {} must be a directory",
2467 target_dir.display()
2468 ),
2469 }),
2470 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
2471 Err(source) => Err(WasmBuildError::Io {
2472 operation,
2473 path: target_dir,
2474 source,
2475 }),
2476 }
2477}
2478
2479fn effective_environment(spec: &WasmBuildSpec) -> BTreeMap<OsString, Option<OsString>> {
2480 let mut names = spec.inherited_env.clone();
2481 names.extend(AUTOMATIC_ENVIRONMENT.iter().map(OsString::from));
2482 let mut environment = names
2483 .into_iter()
2484 .map(|name| {
2485 let value = std::env::var_os(&name);
2486 (name, value)
2487 })
2488 .collect::<BTreeMap<_, _>>();
2489 for (key, value) in &spec.extra_env {
2490 environment.insert(key.clone(), Some(value.clone()));
2491 }
2492 environment
2493}
2494
2495fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
2496 for (key, value) in &spec.extra_env {
2497 command.env(key, value);
2498 }
2499}
2500
2501fn run_cargo_build(
2502 spec: &WasmBuildSpec,
2503 build_target_dir: &Path,
2504 progress: &mut ProgressReporter<'_>,
2505) -> Result<(), WasmBuildError> {
2506 let mut command = Command::new(&spec.cargo_program);
2507 command
2508 .current_dir(&spec.workspace_root)
2509 .env("CARGO_TARGET_DIR", build_target_dir)
2510 .args(["build", "--target", &spec.target])
2511 .args(&spec.cargo_profile_args);
2512 apply_command_environment(&mut command, spec);
2513 for package in &spec.packages {
2514 command.args(["-p", package]);
2515 }
2516
2517 if !progress.is_observed() {
2518 let output = command
2519 .output()
2520 .map_err(|source| WasmBuildError::CommandSpawn {
2521 phase: WasmBuildPhase::CargoBuild,
2522 program: spec.cargo_program.clone(),
2523 source,
2524 })?;
2525 return ensure_command_success(WasmBuildPhase::CargoBuild, output).map(|_| ());
2526 }
2527
2528 run_observed_cargo_build(spec, build_target_dir, command, progress)
2529}
2530
2531fn run_observed_cargo_build(
2532 spec: &WasmBuildSpec,
2533 build_target_dir: &Path,
2534 mut command: Command,
2535 progress: &mut ProgressReporter<'_>,
2536) -> Result<(), WasmBuildError> {
2537 command.stdout(Stdio::piped()).stderr(Stdio::piped());
2538 let started = Instant::now();
2539 let child = command
2540 .spawn()
2541 .map_err(|source| WasmBuildError::CommandSpawn {
2542 phase: WasmBuildPhase::CargoBuild,
2543 program: spec.cargo_program.clone(),
2544 source,
2545 })?;
2546 let mut child = ObservedChild::new(child);
2547 progress.emit(WasmBuildProgressEvent::CargoStarted {
2548 target_dir: build_target_dir.to_owned(),
2549 });
2550
2551 let stdout = child
2552 .child_mut()
2553 .stdout
2554 .take()
2555 .expect("Cargo stdout must be piped");
2556 let stderr = child
2557 .child_mut()
2558 .stderr
2559 .take()
2560 .expect("Cargo stderr must be piped");
2561 let (sender, chunks) = mpsc::channel();
2562 let stdout_sender = sender.clone();
2563 let stdout_reader = thread::spawn(move || {
2564 read_process_output(stdout, WasmBuildOutputStream::Stdout, stdout_sender)
2565 });
2566 let stderr_reader =
2567 thread::spawn(move || read_process_output(stderr, WasmBuildOutputStream::Stderr, sender));
2568
2569 let captured = capture_observed_cargo_output(chunks, progress, started);
2570
2571 let status = child.wait().map_err(|source| WasmBuildError::Io {
2572 operation: "wait for observed cargo build",
2573 path: PathBuf::from(&spec.cargo_program),
2574 source,
2575 })?;
2576 join_output_reader(
2577 stdout_reader,
2578 "read observed cargo stdout",
2579 &spec.cargo_program,
2580 )?;
2581 join_output_reader(
2582 stderr_reader,
2583 "read observed cargo stderr",
2584 &spec.cargo_program,
2585 )?;
2586 let elapsed = started.elapsed();
2587 progress.emit(WasmBuildProgressEvent::CargoFinished {
2588 success: status.success(),
2589 code: status.code(),
2590 elapsed,
2591 });
2592
2593 ensure_command_success(
2594 WasmBuildPhase::CargoBuild,
2595 Output {
2596 status,
2597 stdout: captured.stdout,
2598 stderr: captured.stderr,
2599 },
2600 )
2601 .map(|_| ())
2602}
2603
2604struct CapturedProcessOutput {
2605 stdout: Vec<u8>,
2606 stderr: Vec<u8>,
2607}
2608
2609fn capture_observed_cargo_output(
2610 chunks: mpsc::Receiver<ProcessOutputChunk>,
2611 progress: &mut ProgressReporter<'_>,
2612 started: Instant,
2613) -> CapturedProcessOutput {
2614 let mut stdout = Vec::new();
2615 let mut stderr = Vec::new();
2616 let mut last_emitted = Instant::now();
2617 loop {
2618 let message = match progress.config.heartbeat_interval {
2619 Some(interval) => {
2620 let quiet_for = last_emitted.elapsed();
2621 if quiet_for >= interval {
2622 progress.emit(WasmBuildProgressEvent::CargoHeartbeat {
2623 elapsed: started.elapsed(),
2624 });
2625 last_emitted = Instant::now();
2626 None
2627 } else {
2628 match chunks.recv_timeout(interval.saturating_sub(quiet_for)) {
2629 Ok(chunk) => Some(chunk),
2630 Err(RecvTimeoutError::Timeout) => {
2631 progress.emit(WasmBuildProgressEvent::CargoHeartbeat {
2632 elapsed: started.elapsed(),
2633 });
2634 last_emitted = Instant::now();
2635 None
2636 }
2637 Err(RecvTimeoutError::Disconnected) => break,
2638 }
2639 }
2640 }
2641 None => match chunks.recv() {
2642 Ok(chunk) => Some(chunk),
2643 Err(_) => break,
2644 },
2645 };
2646 let Some(chunk) = message else {
2647 continue;
2648 };
2649 match chunk.stream {
2650 WasmBuildOutputStream::Stdout => stdout.extend_from_slice(&chunk.bytes),
2651 WasmBuildOutputStream::Stderr => stderr.extend_from_slice(&chunk.bytes),
2652 }
2653 if progress.config.emit_cargo_output {
2654 progress.emit(WasmBuildProgressEvent::CargoOutput {
2655 stream: chunk.stream,
2656 bytes: chunk.bytes,
2657 });
2658 last_emitted = Instant::now();
2659 }
2660 }
2661 CapturedProcessOutput { stdout, stderr }
2662}
2663
2664#[derive(Debug)]
2665struct ProcessOutputChunk {
2666 stream: WasmBuildOutputStream,
2667 bytes: Vec<u8>,
2668}
2669
2670fn read_process_output<R: io::Read>(
2671 mut reader: R,
2672 stream: WasmBuildOutputStream,
2673 sender: mpsc::Sender<ProcessOutputChunk>,
2674) -> io::Result<()> {
2675 let mut buffer = [0_u8; 8 * 1024];
2676 loop {
2677 let count = reader.read(&mut buffer)?;
2678 if count == 0 {
2679 return Ok(());
2680 }
2681 if sender
2682 .send(ProcessOutputChunk {
2683 stream,
2684 bytes: buffer[..count].to_vec(),
2685 })
2686 .is_err()
2687 {
2688 return Ok(());
2689 }
2690 }
2691}
2692
2693fn join_output_reader(
2694 reader: thread::JoinHandle<io::Result<()>>,
2695 operation: &'static str,
2696 cargo_program: &OsStr,
2697) -> Result<(), WasmBuildError> {
2698 let result = reader.join().map_err(|_| WasmBuildError::Io {
2699 operation,
2700 path: PathBuf::from(cargo_program),
2701 source: io::Error::other("Cargo output reader panicked"),
2702 })?;
2703 result.map_err(|source| WasmBuildError::Io {
2704 operation,
2705 path: PathBuf::from(cargo_program),
2706 source,
2707 })
2708}
2709
2710struct ObservedChild(Option<Child>);
2711
2712impl ObservedChild {
2713 const fn new(child: Child) -> Self {
2714 Self(Some(child))
2715 }
2716
2717 const fn child_mut(&mut self) -> &mut Child {
2718 self.0.as_mut().expect("observed child must be present")
2719 }
2720
2721 fn wait(&mut self) -> io::Result<ExitStatus> {
2722 let status = self.child_mut().wait()?;
2723 self.0.take();
2724 Ok(status)
2725 }
2726}
2727
2728impl Drop for ObservedChild {
2729 fn drop(&mut self) {
2730 if let Some(mut child) = self.0.take() {
2731 let _ = child.kill();
2732 let _ = child.wait();
2733 }
2734 }
2735}
2736
2737fn ensure_command_success(phase: WasmBuildPhase, output: Output) -> Result<Output, WasmBuildError> {
2738 if output.status.success() {
2739 return Ok(output);
2740 }
2741 Err(WasmBuildError::CommandFailed {
2742 phase,
2743 status: output.status,
2744 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
2745 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
2746 })
2747}
2748
2749fn expected_artifacts(spec: &WasmBuildSpec, target_dir: &Path) -> Vec<PathBuf> {
2750 let mut packages = spec.packages.iter().map(String::as_str).collect::<Vec<_>>();
2751 packages.sort_unstable();
2752 packages.dedup();
2753 packages
2754 .into_iter()
2755 .map(|package| {
2756 if spec.target == DEFAULT_TARGET {
2757 wasm_path(target_dir, package, &spec.profile_target_dir)
2758 } else {
2759 target_dir
2760 .join(&spec.target)
2761 .join(&spec.profile_target_dir)
2762 .join(format!("{package}.wasm"))
2763 }
2764 })
2765 .collect()
2766}
2767
2768fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
2769 spec.target_dir
2770 .join(".ic-testkit/wasm-targets")
2771 .join(fingerprint.to_hex())
2772}
2773
2774fn artifact_set_matches(artifacts: &[PathBuf], fingerprint: InputDigest) -> bool {
2775 artifacts.iter().all(|path| {
2776 fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
2777 && cache_stamp_matches(path, fingerprint)
2778 })
2779}
2780
2781fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
2782 artifacts
2783 .iter()
2784 .filter(|path| {
2785 fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
2786 })
2787 .cloned()
2788 .collect()
2789}
2790
2791fn cache_stamp_matches(artifact: &Path, fingerprint: InputDigest) -> bool {
2792 let stamp_path = artifact_stamp_path(artifact);
2793 let Ok(expected) = artifact_stamp_contents(artifact, fingerprint) else {
2794 return false;
2795 };
2796 fs::read_to_string(stamp_path).is_ok_and(|stamp| stamp == expected)
2797}
2798
2799fn artifact_stamp_path(artifact: &Path) -> PathBuf {
2800 let mut name = artifact
2801 .file_name()
2802 .map_or_else(|| OsString::from("artifact"), OsString::from);
2803 name.push(".ic-testkit-build");
2804 artifact.with_file_name(name)
2805}
2806
2807fn artifact_stamp_contents(artifact: &Path, fingerprint: InputDigest) -> io::Result<String> {
2808 let (_, artifact_digest) = digest_file("wasm-artifact-v1", artifact)?;
2809 Ok(format!(
2810 "{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\nartifact-sha256:{artifact_digest}\n"
2811 ))
2812}
2813
2814fn publish_artifact_stamps(
2815 artifacts: &[PathBuf],
2816 fingerprint: InputDigest,
2817) -> Result<(), WasmBuildError> {
2818 for artifact in artifacts {
2819 let stamp_path = artifact_stamp_path(artifact);
2820 let stamp = artifact_stamp_contents(artifact, fingerprint).map_err(|source| {
2821 WasmBuildError::Io {
2822 operation: "hash built Wasm artifact",
2823 path: artifact.clone(),
2824 source,
2825 }
2826 })?;
2827 write_atomic(&stamp_path, stamp.as_bytes()).map_err(|source| WasmBuildError::Io {
2828 operation: "publish Wasm build stamp",
2829 path: stamp_path,
2830 source,
2831 })?;
2832 }
2833 Ok(())
2834}
2835
2836fn materialize_artifacts(
2837 cached_artifacts: &[PathBuf],
2838 artifacts: &[PathBuf],
2839 fingerprint: InputDigest,
2840) -> Result<(), WasmBuildError> {
2841 for (cached, artifact) in cached_artifacts.iter().zip(artifacts) {
2842 copy_file_atomic(cached, artifact).map_err(|source| WasmBuildError::Io {
2843 operation: "publish Wasm artifact",
2844 path: artifact.clone(),
2845 source,
2846 })?;
2847 }
2848 publish_artifact_stamps(artifacts, fingerprint)
2849}
2850
2851fn copy_wasm_artifacts(
2852 source_artifacts: &[PathBuf],
2853 cached_artifacts: &[PathBuf],
2854) -> Result<(), WasmBuildError> {
2855 for (source, cached) in source_artifacts.iter().zip(cached_artifacts) {
2856 copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
2857 operation: "cache shared-incremental Wasm artifact",
2858 path: cached.clone(),
2859 source: source_error,
2860 })?;
2861 }
2862 Ok(())
2863}
2864
2865fn remove_directory_if_present(path: &Path) -> Result<(), WasmBuildError> {
2866 remove_path_if_present(path).map_err(|source| WasmBuildError::Io {
2867 operation: "remove incomplete content-addressed Cargo target directory",
2868 path: path.to_owned(),
2869 source,
2870 })
2871}
2872
2873fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
2874 fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
2875 operation,
2876 path: path.to_owned(),
2877 source,
2878 })
2879}
2880
2881fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
2882 if path.exists() {
2883 inputs.push((PathBuf::from(label), path));
2884 }
2885}
2886
2887fn required_string(value: &Value, field: &str) -> Result<String, WasmBuildError> {
2888 value
2889 .get(field)
2890 .and_then(Value::as_str)
2891 .map(str::to_owned)
2892 .ok_or_else(|| invalid_metadata(&format!("Cargo metadata field `{field}` is missing")))
2893}
2894
2895fn invalid_metadata(message: &str) -> WasmBuildError {
2896 WasmBuildError::InvalidMetadata {
2897 message: message.to_owned(),
2898 }
2899}
2900
2901impl std::fmt::Display for WasmBuildPhase {
2902 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2903 formatter.write_str(match self {
2904 Self::CargoMetadata => "cargo metadata",
2905 Self::CargoIdentity => "Cargo identity",
2906 Self::RustcIdentity => "Rust compiler identity",
2907 Self::CargoBuild => "cargo build",
2908 })
2909 }
2910}
2911
2912impl std::fmt::Display for WasmBuildError {
2913 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2914 match self {
2915 Self::InvalidSpec { message } => {
2916 write!(formatter, "invalid Wasm build spec: {message}")
2917 }
2918 Self::Io {
2919 operation,
2920 path,
2921 source,
2922 } => write!(
2923 formatter,
2924 "failed to {operation} at {}: {source}",
2925 path.display()
2926 ),
2927 Self::CommandSpawn {
2928 phase,
2929 program,
2930 source,
2931 } => write!(
2932 formatter,
2933 "failed to launch {phase} using `{}`: {source}",
2934 program.to_string_lossy(),
2935 ),
2936 Self::CommandFailed {
2937 phase,
2938 status,
2939 stdout,
2940 stderr,
2941 } => write!(
2942 formatter,
2943 "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
2944 ),
2945 Self::InvalidMetadata { message } => {
2946 write!(formatter, "invalid Cargo metadata: {message}")
2947 }
2948 Self::InvalidCargoConfiguration { path, message } => write!(
2949 formatter,
2950 "invalid Cargo configuration at {}: {message}",
2951 path.display(),
2952 ),
2953 Self::MissingArtifacts { paths } => write!(
2954 formatter,
2955 "cargo build succeeded without producing: {}",
2956 paths
2957 .iter()
2958 .map(|path| path.display().to_string())
2959 .collect::<Vec<_>>()
2960 .join(", "),
2961 ),
2962 Self::InputsChangedDuringBuild { before, after } => write!(
2963 formatter,
2964 "Wasm build inputs changed while Cargo was running: {before} -> {after}",
2965 ),
2966 Self::FailedBuildCleanup {
2967 build_error,
2968 path,
2969 source,
2970 } => write!(
2971 formatter,
2972 "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
2973 path.display(),
2974 ),
2975 }
2976 }
2977}
2978
2979impl std::error::Error for WasmBuildError {
2980 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
2981 match self {
2982 Self::Io { source, .. }
2983 | Self::CommandSpawn { source, .. }
2984 | Self::FailedBuildCleanup { source, .. } => Some(source),
2985 _ => None,
2986 }
2987 }
2988}
2989
2990#[cfg(test)]
2991mod tests;