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, 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, remove_path_if_present,
22 },
23 digest::{
24 InputDigest, InputHasher, copy_file_atomic, digest_bytes, digest_file,
25 digest_labeled_paths, os_bytes, write_atomic,
26 },
27 wasm::wasm_path,
28};
29
30const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
31const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
32const AUTOMATIC_ENVIRONMENT: &[&str] = &[
33 "CARGO_BUILD_RUSTC",
34 "CARGO_ENCODED_RUSTFLAGS",
35 "RUSTC",
36 "RUSTC_WRAPPER",
37 "RUSTC_WORKSPACE_WRAPPER",
38 "RUSTFLAGS",
39 "RUSTUP_TOOLCHAIN",
40];
41
42#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct WasmBuildSpec {
50 workspace_root: PathBuf,
51 target_dir: PathBuf,
52 packages: Vec<String>,
53 profile_target_dir: String,
54 cargo_profile_args: Vec<OsString>,
55 extra_env: BTreeMap<OsString, OsString>,
56 inherited_env: BTreeSet<OsString>,
57 additional_inputs: Vec<PathBuf>,
58 target: String,
59 cargo_program: OsString,
60 rustc_program: OsString,
61 cache_mode: WasmBuildCacheMode,
62 prune_policy: Option<WasmBuildCachePrunePolicy>,
63 prune_interval: Option<Duration>,
64}
65
66#[non_exhaustive]
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub enum WasmBuildCacheMode {
70 Isolated,
72 SharedIncremental {
74 target_dir: PathBuf,
76 },
77}
78
79#[derive(Clone, Debug, Eq, PartialEq)]
81pub enum WasmBuildOutcome {
82 Built(WasmBuildRecord),
84 Reused(WasmBuildRecord),
86}
87
88#[derive(Clone, Debug, Eq, PartialEq)]
90pub struct WasmBuildRecord {
91 fingerprint: InputDigest,
92 input_digest: InputDigest,
93 artifacts: Vec<PathBuf>,
94 timings: WasmBuildTimings,
95 maintenance: Option<WasmBuildCacheMaintenance>,
96}
97
98#[derive(Clone, Copy, Debug, Eq, PartialEq)]
100pub struct WasmBuildTimings {
101 lock_wait: Duration,
102 shared_incremental_lock_wait: Option<Duration>,
103 input_resolution: WasmInputResolutionTimings,
104 cargo_build: Option<Duration>,
105 cache_maintenance: Option<Duration>,
106 total: Duration,
107}
108
109#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
111pub struct WasmInputResolutionTimings {
112 tool_identity: Duration,
113 cargo_metadata: Duration,
114 input_discovery: Duration,
115 content_hashing: Duration,
116 total: Duration,
117}
118
119#[derive(Clone, Debug, Eq, PartialEq)]
121pub struct CargoBuildInput {
122 label: PathBuf,
123 path: PathBuf,
124}
125
126#[derive(Clone, Debug, Eq, PartialEq)]
131pub struct ResolvedCargoBuildInputs {
132 fingerprint: InputDigest,
133 input_digest: InputDigest,
134 inputs: Vec<CargoBuildInput>,
135 exclusions: Vec<PathBuf>,
136 timings: WasmInputResolutionTimings,
137}
138
139#[derive(Clone, Debug, Eq, PartialEq)]
141pub struct SharedIncrementalTargetInspection {
142 target_dir: PathBuf,
143 logical_size_bytes: u64,
144 last_used: SystemTime,
145 lock_wait: Duration,
146}
147
148#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
156pub struct SharedIncrementalTargetPrunePolicy {
157 max_age: Option<Duration>,
158 max_size_bytes: Option<u64>,
159}
160
161#[derive(Clone, Debug, Eq, PartialEq)]
163pub struct SharedIncrementalTargetMaintenance {
164 target_dir: PathBuf,
165 logical_size_bytes_before: u64,
166 logical_size_bytes_after: u64,
167 last_used_before: SystemTime,
168 cleared: bool,
169 lock_wait: Duration,
170 maintenance: Duration,
171}
172
173#[derive(Clone, Copy, Debug, Eq, PartialEq)]
175pub struct WasmBuildProgressConfig {
176 heartbeat_interval: Option<Duration>,
177 emit_cargo_output: bool,
178}
179
180#[derive(Clone, Copy, Debug, Eq, PartialEq)]
182pub enum WasmBuildOutputStream {
183 Stdout,
185 Stderr,
187}
188
189#[derive(Clone, Copy, Debug, Eq, PartialEq)]
191pub enum WasmBuildProgressOutcome {
192 Built,
194 Reused,
196}
197
198#[non_exhaustive]
200#[derive(Clone, Debug, Eq, PartialEq)]
201pub enum WasmBuildProgressEvent {
202 Started,
204 InputsResolved {
206 fingerprint: InputDigest,
208 input_digest: InputDigest,
210 elapsed: Duration,
212 },
213 CacheMiss {
215 fingerprint: InputDigest,
217 },
218 CacheHit {
220 fingerprint: InputDigest,
222 },
223 SharedTargetLockStarted {
225 target_dir: PathBuf,
227 },
228 SharedTargetLockAcquired {
230 target_dir: PathBuf,
232 wait: Duration,
234 },
235 CargoStarted {
237 target_dir: PathBuf,
239 },
240 CargoOutput {
242 stream: WasmBuildOutputStream,
244 bytes: Vec<u8>,
246 },
247 CargoHeartbeat {
249 elapsed: Duration,
251 },
252 CargoFinished {
254 success: bool,
256 code: Option<i32>,
258 elapsed: Duration,
260 },
261 Finished {
263 outcome: WasmBuildProgressOutcome,
265 fingerprint: InputDigest,
267 elapsed: Duration,
269 },
270}
271
272impl Default for WasmBuildProgressConfig {
273 fn default() -> Self {
274 Self {
275 heartbeat_interval: Some(Duration::from_secs(10)),
276 emit_cargo_output: true,
277 }
278 }
279}
280
281impl WasmBuildProgressConfig {
282 #[must_use]
284 pub fn new() -> Self {
285 Self::default()
286 }
287
288 #[must_use]
292 pub const fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
293 self.heartbeat_interval = Some(interval);
294 self
295 }
296
297 #[must_use]
299 pub const fn without_heartbeats(mut self) -> Self {
300 self.heartbeat_interval = None;
301 self
302 }
303
304 #[must_use]
308 pub const fn with_cargo_output(mut self, emit: bool) -> Self {
309 self.emit_cargo_output = emit;
310 self
311 }
312
313 #[must_use]
315 pub const fn heartbeat_interval(self) -> Option<Duration> {
316 self.heartbeat_interval
317 }
318
319 #[must_use]
321 pub const fn emits_cargo_output(self) -> bool {
322 self.emit_cargo_output
323 }
324}
325
326struct ProgressReporter<'a> {
327 config: WasmBuildProgressConfig,
328 observer: Option<&'a mut dyn FnMut(WasmBuildProgressEvent)>,
329}
330
331impl ProgressReporter<'_> {
332 fn silent() -> Self {
333 Self {
334 config: WasmBuildProgressConfig {
335 heartbeat_interval: None,
336 emit_cargo_output: false,
337 },
338 observer: None,
339 }
340 }
341
342 fn emit(&mut self, event: WasmBuildProgressEvent) {
343 if let Some(observer) = &mut self.observer {
344 observer(event);
345 }
346 }
347
348 const fn is_observed(&self) -> bool {
349 self.observer.is_some()
350 }
351}
352
353pub type WasmBuildCachePrunePolicy = ArtifactCachePrunePolicy;
355
356pub type WasmBuildCachePruneReport = ArtifactCachePruneReport;
358
359pub type WasmBuildCacheMaintenance = ArtifactCacheMaintenance;
361
362#[non_exhaustive]
364#[derive(Clone, Copy, Debug, Eq, PartialEq)]
365pub enum WasmBuildPhase {
366 CargoMetadata,
368 CargoIdentity,
370 RustcIdentity,
372 CargoBuild,
374}
375
376#[non_exhaustive]
378#[derive(Debug)]
379pub enum WasmBuildError {
380 InvalidSpec { message: String },
382 Io {
384 operation: &'static str,
385 path: PathBuf,
386 source: io::Error,
387 },
388 CommandSpawn {
390 phase: WasmBuildPhase,
391 program: OsString,
392 source: io::Error,
393 },
394 CommandFailed {
396 phase: WasmBuildPhase,
397 status: ExitStatus,
398 stdout: String,
399 stderr: String,
400 },
401 InvalidMetadata { message: String },
403 InvalidCargoConfiguration { path: PathBuf, message: String },
405 MissingArtifacts { paths: Vec<PathBuf> },
407 InputsChangedDuringBuild {
409 before: InputDigest,
410 after: InputDigest,
411 },
412 FailedBuildCleanup {
414 build_error: Box<Self>,
415 path: PathBuf,
416 source: io::Error,
417 },
418}
419
420impl WasmBuildSpec {
421 #[must_use]
426 pub fn new(
427 workspace_root: &Path,
428 target_dir: &Path,
429 packages: &[&str],
430 profile_target_dir: &str,
431 ) -> Self {
432 Self {
433 workspace_root: workspace_root.to_owned(),
434 target_dir: target_dir.to_owned(),
435 packages: packages
436 .iter()
437 .map(|package| (*package).to_owned())
438 .collect(),
439 profile_target_dir: profile_target_dir.to_owned(),
440 cargo_profile_args: Vec::new(),
441 extra_env: BTreeMap::new(),
442 inherited_env: BTreeSet::new(),
443 additional_inputs: Vec::new(),
444 target: DEFAULT_TARGET.to_owned(),
445 cargo_program: std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()),
446 rustc_program: std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()),
447 cache_mode: WasmBuildCacheMode::Isolated,
448 prune_policy: None,
449 prune_interval: None,
450 }
451 }
452
453 #[must_use]
455 pub fn with_cargo_profile_args(mut self, arguments: &[&str]) -> Self {
456 self.cargo_profile_args = arguments.iter().map(OsString::from).collect();
457 self
458 }
459
460 #[must_use]
462 pub fn with_cargo_profile_args_os<I, S>(mut self, arguments: I) -> Self
463 where
464 I: IntoIterator<Item = S>,
465 S: Into<OsString>,
466 {
467 self.cargo_profile_args = arguments.into_iter().map(Into::into).collect();
468 self
469 }
470
471 #[must_use]
473 pub fn with_extra_env(mut self, environment: &[(&str, &str)]) -> Self {
474 self.extra_env = environment
475 .iter()
476 .map(|(key, value)| (OsString::from(key), OsString::from(value)))
477 .collect();
478 self
479 }
480
481 #[must_use]
483 pub fn with_extra_env_os<I, K, V>(mut self, environment: I) -> Self
484 where
485 I: IntoIterator<Item = (K, V)>,
486 K: Into<OsString>,
487 V: Into<OsString>,
488 {
489 self.extra_env = environment
490 .into_iter()
491 .map(|(key, value)| (key.into(), value.into()))
492 .collect();
493 self
494 }
495
496 #[must_use]
501 pub fn with_inherited_env(mut self, names: &[&str]) -> Self {
502 self.inherited_env.extend(names.iter().map(OsString::from));
503 self
504 }
505
506 #[must_use]
508 pub fn with_inherited_env_os<I, S>(mut self, names: I) -> Self
509 where
510 I: IntoIterator<Item = S>,
511 S: Into<OsString>,
512 {
513 self.inherited_env.extend(names.into_iter().map(Into::into));
514 self
515 }
516
517 #[must_use]
522 pub fn with_additional_inputs(mut self, paths: &[&str]) -> Self {
523 self.additional_inputs
524 .extend(paths.iter().map(PathBuf::from));
525 self
526 }
527
528 #[must_use]
530 pub fn with_additional_input_paths<I, P>(mut self, paths: I) -> Self
531 where
532 I: IntoIterator<Item = P>,
533 P: Into<PathBuf>,
534 {
535 self.additional_inputs
536 .extend(paths.into_iter().map(Into::into));
537 self
538 }
539
540 #[must_use]
542 pub fn with_target(mut self, target: &str) -> Self {
543 target.clone_into(&mut self.target);
544 self
545 }
546
547 #[must_use]
549 pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
550 self.cargo_program = program.into();
551 self
552 }
553
554 #[must_use]
556 pub fn with_rustc_program(mut self, program: impl Into<OsString>) -> Self {
557 self.rustc_program = program.into();
558 self
559 }
560
561 #[must_use]
567 pub fn with_shared_incremental_target(mut self, target_dir: impl Into<PathBuf>) -> Self {
568 self.cache_mode = WasmBuildCacheMode::SharedIncremental {
569 target_dir: target_dir.into(),
570 };
571 self
572 }
573
574 #[must_use]
580 pub const fn with_prune_policy(mut self, policy: WasmBuildCachePrunePolicy) -> Self {
581 self.prune_policy = Some(policy);
582 self.prune_interval = None;
583 self
584 }
585
586 #[must_use]
593 pub const fn with_prune_policy_at_most_every(
594 mut self,
595 policy: WasmBuildCachePrunePolicy,
596 minimum_interval: Duration,
597 ) -> Self {
598 self.prune_policy = Some(policy);
599 self.prune_interval = Some(minimum_interval);
600 self
601 }
602
603 #[must_use]
605 pub fn workspace_root(&self) -> &Path {
606 &self.workspace_root
607 }
608
609 #[must_use]
611 pub fn target_dir(&self) -> &Path {
612 &self.target_dir
613 }
614
615 #[must_use]
617 pub fn packages(&self) -> &[String] {
618 &self.packages
619 }
620
621 #[must_use]
623 pub const fn cache_mode(&self) -> &WasmBuildCacheMode {
624 &self.cache_mode
625 }
626}
627
628impl WasmBuildOutcome {
629 #[must_use]
631 pub const fn record(&self) -> &WasmBuildRecord {
632 match self {
633 Self::Built(record) | Self::Reused(record) => record,
634 }
635 }
636
637 #[must_use]
639 pub const fn is_reused(&self) -> bool {
640 matches!(self, Self::Reused(_))
641 }
642}
643
644impl WasmBuildRecord {
645 #[must_use]
647 pub const fn fingerprint(&self) -> InputDigest {
648 self.fingerprint
649 }
650
651 #[must_use]
653 pub const fn input_digest(&self) -> InputDigest {
654 self.input_digest
655 }
656
657 #[must_use]
659 pub fn artifacts(&self) -> &[PathBuf] {
660 &self.artifacts
661 }
662
663 #[must_use]
665 pub const fn timings(&self) -> WasmBuildTimings {
666 self.timings
667 }
668
669 #[must_use]
671 pub const fn maintenance(&self) -> Option<&WasmBuildCacheMaintenance> {
672 self.maintenance.as_ref()
673 }
674}
675
676impl WasmBuildTimings {
677 #[must_use]
679 pub const fn lock_wait(self) -> Duration {
680 self.lock_wait
681 }
682
683 #[must_use]
685 pub const fn shared_incremental_lock_wait(self) -> Option<Duration> {
686 self.shared_incremental_lock_wait
687 }
688
689 #[must_use]
691 pub const fn input_resolution(self) -> Duration {
692 self.input_resolution.total
693 }
694
695 #[must_use]
697 pub const fn input_resolution_detail(self) -> WasmInputResolutionTimings {
698 self.input_resolution
699 }
700
701 #[must_use]
703 pub const fn cargo_build(self) -> Option<Duration> {
704 self.cargo_build
705 }
706
707 #[must_use]
709 pub const fn cache_maintenance(self) -> Option<Duration> {
710 self.cache_maintenance
711 }
712
713 #[must_use]
715 pub const fn total(self) -> Duration {
716 self.total
717 }
718}
719
720impl WasmInputResolutionTimings {
721 #[must_use]
723 pub const fn tool_identity(self) -> Duration {
724 self.tool_identity
725 }
726
727 #[must_use]
729 pub const fn cargo_metadata(self) -> Duration {
730 self.cargo_metadata
731 }
732
733 #[must_use]
735 pub const fn input_discovery(self) -> Duration {
736 self.input_discovery
737 }
738
739 #[must_use]
741 pub const fn content_hashing(self) -> Duration {
742 self.content_hashing
743 }
744
745 #[must_use]
747 pub const fn total(self) -> Duration {
748 self.total
749 }
750
751 const fn include(&mut self, other: Self) {
752 self.tool_identity = self.tool_identity.saturating_add(other.tool_identity);
753 self.cargo_metadata = self.cargo_metadata.saturating_add(other.cargo_metadata);
754 self.input_discovery = self.input_discovery.saturating_add(other.input_discovery);
755 self.content_hashing = self.content_hashing.saturating_add(other.content_hashing);
756 self.total = self.total.saturating_add(other.total);
757 }
758}
759
760impl CargoBuildInput {
761 #[must_use]
763 pub fn label(&self) -> &Path {
764 &self.label
765 }
766
767 #[must_use]
769 pub fn path(&self) -> &Path {
770 &self.path
771 }
772}
773
774impl ResolvedCargoBuildInputs {
775 #[must_use]
777 pub const fn fingerprint(&self) -> InputDigest {
778 self.fingerprint
779 }
780
781 #[must_use]
783 pub const fn input_digest(&self) -> InputDigest {
784 self.input_digest
785 }
786
787 #[must_use]
789 pub fn inputs(&self) -> &[CargoBuildInput] {
790 &self.inputs
791 }
792
793 #[must_use]
798 pub fn exclusions(&self) -> &[PathBuf] {
799 &self.exclusions
800 }
801
802 #[must_use]
804 pub const fn timings(&self) -> WasmInputResolutionTimings {
805 self.timings
806 }
807
808 pub fn is_current(&self, spec: &WasmBuildSpec) -> Result<bool, WasmBuildError> {
810 resolve_cargo_build_inputs(spec).map(|current| current.fingerprint == self.fingerprint)
811 }
812
813 pub fn is_content_current(&self) -> Result<bool, WasmBuildError> {
820 self.current_input_digest()
821 .map(|current| current == self.input_digest)
822 }
823
824 pub(super) fn current_input_digest(&self) -> Result<InputDigest, WasmBuildError> {
825 let inputs = self
826 .inputs
827 .iter()
828 .map(|input| (input.label.clone(), input.path.clone()))
829 .collect::<Vec<_>>();
830 digest_labeled_paths("wasm-source-inputs-v1", &inputs, &self.exclusions).map_err(|source| {
831 WasmBuildError::Io {
832 operation: "rehash resolved Cargo build inputs",
833 path: self
834 .inputs
835 .first()
836 .map_or_else(PathBuf::new, |input| input.path.clone()),
837 source,
838 }
839 })
840 }
841}
842
843impl SharedIncrementalTargetInspection {
844 #[must_use]
846 pub fn target_dir(&self) -> &Path {
847 &self.target_dir
848 }
849
850 #[must_use]
852 pub const fn logical_size_bytes(&self) -> u64 {
853 self.logical_size_bytes
854 }
855
856 #[must_use]
858 pub const fn last_used(&self) -> SystemTime {
859 self.last_used
860 }
861
862 #[must_use]
864 pub const fn lock_wait(&self) -> Duration {
865 self.lock_wait
866 }
867}
868
869impl SharedIncrementalTargetPrunePolicy {
870 #[must_use]
872 pub const fn new() -> Self {
873 Self {
874 max_age: None,
875 max_size_bytes: None,
876 }
877 }
878
879 #[must_use]
881 pub const fn with_max_age(mut self, max_age: Duration) -> Self {
882 self.max_age = Some(max_age);
883 self
884 }
885
886 #[must_use]
888 pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
889 self.max_size_bytes = Some(bytes);
890 self
891 }
892
893 #[must_use]
895 pub const fn max_age(self) -> Option<Duration> {
896 self.max_age
897 }
898
899 #[must_use]
901 pub const fn max_size_bytes(self) -> Option<u64> {
902 self.max_size_bytes
903 }
904}
905
906impl SharedIncrementalTargetMaintenance {
907 #[must_use]
909 pub fn target_dir(&self) -> &Path {
910 &self.target_dir
911 }
912
913 #[must_use]
915 pub const fn logical_size_bytes_before(&self) -> u64 {
916 self.logical_size_bytes_before
917 }
918
919 #[must_use]
921 pub const fn logical_size_bytes_after(&self) -> u64 {
922 self.logical_size_bytes_after
923 }
924
925 #[must_use]
927 pub const fn last_used_before(&self) -> SystemTime {
928 self.last_used_before
929 }
930
931 #[must_use]
933 pub const fn was_cleared(&self) -> bool {
934 self.cleared
935 }
936
937 #[must_use]
939 pub const fn lock_wait(&self) -> Duration {
940 self.lock_wait
941 }
942
943 #[must_use]
945 pub const fn maintenance(&self) -> Duration {
946 self.maintenance
947 }
948}
949
950impl std::fmt::Display for SharedIncrementalTargetMaintenance {
951 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
952 write!(
953 formatter,
954 "target={} action={} bytes={}=>{} lock={:?} maintenance={:?}",
955 self.target_dir.display(),
956 if self.cleared { "cleared" } else { "retained" },
957 self.logical_size_bytes_before,
958 self.logical_size_bytes_after,
959 self.lock_wait,
960 self.maintenance,
961 )
962 }
963}
964
965impl std::fmt::Display for WasmBuildTimings {
966 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
967 write!(
968 formatter,
969 "total={:?} lock={:?} shared_lock={:?} inputs={:?} cargo={:?} maintenance={:?}",
970 self.total,
971 self.lock_wait,
972 self.shared_incremental_lock_wait,
973 self.input_resolution.total,
974 self.cargo_build,
975 self.cache_maintenance,
976 )
977 }
978}
979
980impl std::fmt::Display for WasmBuildOutcome {
981 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
982 let state = if self.is_reused() { "reused" } else { "built" };
983 write!(
984 formatter,
985 "{state} fingerprint={} artifacts={} {}",
986 self.record().fingerprint,
987 self.record().artifacts.len(),
988 self.record().timings,
989 )
990 }
991}
992
993pub fn resolve_cargo_build_inputs(
998 spec: &WasmBuildSpec,
999) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1000 validate_spec(spec)?;
1001 build_fingerprint(spec)
1002}
1003
1004pub fn inspect_shared_incremental_target(
1009 spec: &WasmBuildSpec,
1010) -> Result<Option<SharedIncrementalTargetInspection>, WasmBuildError> {
1011 let target_dir =
1012 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
1013 message: "shared incremental target is not configured".to_owned(),
1014 })?;
1015 match fs::symlink_metadata(&target_dir) {
1016 Ok(metadata) if metadata.is_dir() => {}
1017 Ok(_) => {
1018 return Err(WasmBuildError::InvalidSpec {
1019 message: format!(
1020 "shared incremental Cargo target {} must be a directory",
1021 target_dir.display()
1022 ),
1023 });
1024 }
1025 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1026 Err(source) => {
1027 return Err(WasmBuildError::Io {
1028 operation: "inspect shared incremental Cargo target",
1029 path: target_dir,
1030 source,
1031 });
1032 }
1033 }
1034
1035 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
1036 let logical_size_bytes =
1037 directory_logical_size(&canonical).map_err(|source| WasmBuildError::Io {
1038 operation: "measure shared incremental Cargo target",
1039 path: canonical.clone(),
1040 source,
1041 })?;
1042 let last_used = cache_entry_last_used(&canonical).map_err(|source| WasmBuildError::Io {
1043 operation: "read shared incremental Cargo target use time",
1044 path: canonical.clone(),
1045 source,
1046 })?;
1047 Ok(Some(SharedIncrementalTargetInspection {
1048 target_dir: canonical,
1049 logical_size_bytes,
1050 last_used,
1051 lock_wait,
1052 }))
1053}
1054
1055pub fn maintain_shared_incremental_target(
1069 spec: &WasmBuildSpec,
1070 policy: SharedIncrementalTargetPrunePolicy,
1071) -> Result<Option<SharedIncrementalTargetMaintenance>, WasmBuildError> {
1072 let target_dir =
1073 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
1074 message: "shared incremental target is not configured".to_owned(),
1075 })?;
1076 match fs::symlink_metadata(&target_dir) {
1077 Ok(metadata) if metadata.is_dir() => {}
1078 Ok(_) => {
1079 return Err(WasmBuildError::InvalidSpec {
1080 message: format!(
1081 "shared incremental Cargo target {} must be a directory",
1082 target_dir.display()
1083 ),
1084 });
1085 }
1086 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1087 Err(source) => {
1088 return Err(WasmBuildError::Io {
1089 operation: "inspect shared incremental Cargo target before maintenance",
1090 path: target_dir,
1091 source,
1092 });
1093 }
1094 }
1095
1096 let _ = resolve_cargo_build_inputs(spec)?;
1100 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
1101 let started = Instant::now();
1102 let logical_size_bytes_before =
1103 directory_logical_size(&canonical).map_err(|source| WasmBuildError::Io {
1104 operation: "measure shared incremental Cargo target before maintenance",
1105 path: canonical.clone(),
1106 source,
1107 })?;
1108 let last_used_before =
1109 cache_entry_last_used(&canonical).map_err(|source| WasmBuildError::Io {
1110 operation: "read shared incremental Cargo target use time before maintenance",
1111 path: canonical.clone(),
1112 source,
1113 })?;
1114 let expired = policy.max_age.is_some_and(|max_age| {
1115 SystemTime::now()
1116 .duration_since(last_used_before)
1117 .is_ok_and(|age| age > max_age)
1118 });
1119 let oversized = policy
1120 .max_size_bytes
1121 .is_some_and(|max_size_bytes| logical_size_bytes_before > max_size_bytes);
1122 let cleared = expired || oversized;
1123 if cleared {
1124 clear_shared_incremental_target_contents(&canonical)?;
1125 record_cache_entry_use(&canonical)?;
1126 }
1127 let logical_size_bytes_after = if cleared {
1128 directory_logical_size(&canonical).map_err(|source| WasmBuildError::Io {
1129 operation: "measure shared incremental Cargo target after maintenance",
1130 path: canonical.clone(),
1131 source,
1132 })?
1133 } else {
1134 logical_size_bytes_before
1135 };
1136 Ok(Some(SharedIncrementalTargetMaintenance {
1137 target_dir: canonical,
1138 logical_size_bytes_before,
1139 logical_size_bytes_after,
1140 last_used_before,
1141 cleared,
1142 lock_wait,
1143 maintenance: started.elapsed(),
1144 }))
1145}
1146
1147fn clear_shared_incremental_target_contents(target_dir: &Path) -> Result<(), WasmBuildError> {
1148 let entries = fs::read_dir(target_dir).map_err(|source| WasmBuildError::Io {
1149 operation: "read shared incremental Cargo target for maintenance",
1150 path: target_dir.to_owned(),
1151 source,
1152 })?;
1153 for entry in entries {
1154 let path = entry
1155 .map_err(|source| WasmBuildError::Io {
1156 operation: "read shared incremental Cargo target entry for maintenance",
1157 path: target_dir.to_owned(),
1158 source,
1159 })?
1160 .path();
1161 let preserved = path
1162 .file_name()
1163 .is_some_and(|name| name == ".ic-testkit" || name == "CACHEDIR.TAG");
1164 if !preserved {
1165 remove_path_if_present(&path).map_err(|source| WasmBuildError::Io {
1166 operation: "clear shared incremental Cargo target entry",
1167 path,
1168 source,
1169 })?;
1170 }
1171 }
1172 Ok(())
1173}
1174
1175pub fn build_wasm_canisters_cached(
1182 spec: &WasmBuildSpec,
1183) -> Result<WasmBuildOutcome, WasmBuildError> {
1184 build_wasm_canisters_cached_internal(spec, &mut ProgressReporter::silent())
1185}
1186
1187pub fn build_wasm_canisters_cached_with_progress<F>(
1195 spec: &WasmBuildSpec,
1196 config: WasmBuildProgressConfig,
1197 mut observer: F,
1198) -> Result<WasmBuildOutcome, WasmBuildError>
1199where
1200 F: FnMut(WasmBuildProgressEvent),
1201{
1202 if config.heartbeat_interval == Some(Duration::ZERO) {
1203 return Err(WasmBuildError::InvalidSpec {
1204 message: "Wasm build progress heartbeat interval must be greater than zero".to_owned(),
1205 });
1206 }
1207 build_wasm_canisters_cached_internal(
1208 spec,
1209 &mut ProgressReporter {
1210 config,
1211 observer: Some(&mut observer),
1212 },
1213 )
1214}
1215
1216fn build_wasm_canisters_cached_internal(
1217 spec: &WasmBuildSpec,
1218 progress: &mut ProgressReporter<'_>,
1219) -> Result<WasmBuildOutcome, WasmBuildError> {
1220 let total_started = Instant::now();
1221 validate_spec(spec)?;
1222 progress.emit(WasmBuildProgressEvent::Started);
1223 let (cache_lock, first_lock_wait) = lock_wasm_build_cache(&spec.target_dir)?;
1224 ensure_cache_directory_tag(&spec.target_dir)?;
1225
1226 let resolved = resolve_inputs_with_progress(spec, progress)?;
1227 if let Some(outcome) =
1228 try_reuse_wasm_artifacts(spec, &resolved, first_lock_wait, None, total_started)?
1229 {
1230 emit_finished_progress(&outcome, progress);
1231 return Ok(outcome);
1232 }
1233 progress.emit(WasmBuildProgressEvent::CacheMiss {
1234 fingerprint: resolved.fingerprint,
1235 });
1236
1237 let outcome = match &spec.cache_mode {
1238 WasmBuildCacheMode::Isolated => {
1239 let cache_entry = cache_entry_directory(spec, resolved.fingerprint);
1240 build_wasm_cache_miss(
1241 spec,
1242 resolved,
1243 first_lock_wait,
1244 None,
1245 cache_entry,
1246 total_started,
1247 progress,
1248 )
1249 }
1250 WasmBuildCacheMode::SharedIncremental { .. } => {
1251 drop(cache_lock);
1252 let configured_target = shared_incremental_target(spec)
1253 .expect("shared cache mode must resolve a shared Cargo target");
1254 progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted {
1255 target_dir: configured_target,
1256 });
1257 let (shared_lock, shared_lock_wait, shared_target) =
1258 lock_shared_incremental_target(spec)?;
1259 progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
1260 target_dir: shared_target.clone(),
1261 wait: shared_lock_wait,
1262 });
1263 let (_cache_lock, second_lock_wait) = lock_wasm_build_cache(&spec.target_dir)?;
1264 ensure_cache_directory_tag(&spec.target_dir)?;
1265
1266 let mut current = resolve_inputs_with_progress(spec, progress)?;
1267 current.timings.include(resolved.timings);
1268 let lock_wait = first_lock_wait.saturating_add(second_lock_wait);
1269 if let Some(outcome) = try_reuse_wasm_artifacts(
1270 spec,
1271 ¤t,
1272 lock_wait,
1273 Some(shared_lock_wait),
1274 total_started,
1275 )? {
1276 emit_finished_progress(&outcome, progress);
1277 return Ok(outcome);
1278 }
1279
1280 let outcome = build_wasm_cache_miss(
1281 spec,
1282 current,
1283 lock_wait,
1284 Some(shared_lock_wait),
1285 shared_target,
1286 total_started,
1287 progress,
1288 );
1289 drop(shared_lock);
1290 outcome
1291 }
1292 }?;
1293 emit_finished_progress(&outcome, progress);
1294 Ok(outcome)
1295}
1296
1297fn resolve_inputs_with_progress(
1298 spec: &WasmBuildSpec,
1299 progress: &mut ProgressReporter<'_>,
1300) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1301 let resolved = build_fingerprint(spec)?;
1302 progress.emit(WasmBuildProgressEvent::InputsResolved {
1303 fingerprint: resolved.fingerprint,
1304 input_digest: resolved.input_digest,
1305 elapsed: resolved.timings.total,
1306 });
1307 Ok(resolved)
1308}
1309
1310fn emit_finished_progress(outcome: &WasmBuildOutcome, progress: &mut ProgressReporter<'_>) {
1311 let state = if outcome.is_reused() {
1312 progress.emit(WasmBuildProgressEvent::CacheHit {
1313 fingerprint: outcome.record().fingerprint,
1314 });
1315 WasmBuildProgressOutcome::Reused
1316 } else {
1317 WasmBuildProgressOutcome::Built
1318 };
1319 progress.emit(WasmBuildProgressEvent::Finished {
1320 outcome: state,
1321 fingerprint: outcome.record().fingerprint,
1322 elapsed: outcome.record().timings.total,
1323 });
1324}
1325
1326fn try_reuse_wasm_artifacts(
1327 spec: &WasmBuildSpec,
1328 resolved: &ResolvedCargoBuildInputs,
1329 lock_wait: Duration,
1330 shared_incremental_lock_wait: Option<Duration>,
1331 total_started: Instant,
1332) -> Result<Option<WasmBuildOutcome>, WasmBuildError> {
1333 let fingerprint = resolved.fingerprint;
1334 let artifacts = expected_artifacts(spec, &spec.target_dir);
1335 let cache_entry = cache_entry_directory(spec, fingerprint);
1336 if artifact_set_matches(&artifacts, fingerprint) {
1337 record_cache_entry_use_if_present(&cache_entry)?;
1338 return Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
1339 spec,
1340 BuildRecordInput {
1341 fingerprint,
1342 input_digest: resolved.input_digest,
1343 artifacts,
1344 lock_wait,
1345 shared_incremental_lock_wait,
1346 input_resolution: resolved.timings,
1347 cargo_build: None,
1348 active_entry: &cache_entry,
1349 },
1350 total_started,
1351 ))));
1352 }
1353
1354 let cached_artifacts = expected_artifacts(spec, &cache_entry);
1355 if !artifact_set_matches(&cached_artifacts, fingerprint) {
1356 return Ok(None);
1357 }
1358 materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
1359 record_cache_entry_use(&cache_entry)?;
1360 Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
1361 spec,
1362 BuildRecordInput {
1363 fingerprint,
1364 input_digest: resolved.input_digest,
1365 artifacts,
1366 lock_wait,
1367 shared_incremental_lock_wait,
1368 input_resolution: resolved.timings,
1369 cargo_build: None,
1370 active_entry: &cache_entry,
1371 },
1372 total_started,
1373 ))))
1374}
1375
1376fn build_wasm_cache_miss(
1377 spec: &WasmBuildSpec,
1378 resolved: ResolvedCargoBuildInputs,
1379 lock_wait: Duration,
1380 shared_incremental_lock_wait: Option<Duration>,
1381 cargo_target_dir: PathBuf,
1382 total_started: Instant,
1383 progress: &mut ProgressReporter<'_>,
1384) -> Result<WasmBuildOutcome, WasmBuildError> {
1385 let fingerprint = resolved.fingerprint;
1386 let mut input_resolution = resolved.timings;
1387 let artifacts = expected_artifacts(spec, &spec.target_dir);
1388 let cache_entry = cache_entry_directory(spec, fingerprint);
1389 remove_directory_if_present(&cache_entry)?;
1390 create_dir_all(
1391 &cache_entry,
1392 "create content-addressed Cargo target directory",
1393 )?;
1394 let incomplete_directory = IncompleteBuildDirectory::new(cache_entry.clone());
1395 let build_result = (|| {
1396 if matches!(
1397 spec.cache_mode,
1398 WasmBuildCacheMode::SharedIncremental { .. }
1399 ) {
1400 record_cache_entry_use(&cargo_target_dir)?;
1401 }
1402 let build_started = Instant::now();
1403 run_cargo_build(spec, &cargo_target_dir, progress)?;
1404 let cargo_build = build_started.elapsed();
1405 let built_artifacts = expected_artifacts(spec, &cargo_target_dir);
1406 let missing = missing_artifacts(&built_artifacts);
1407 if !missing.is_empty() {
1408 return Err(WasmBuildError::MissingArtifacts { paths: missing });
1409 }
1410
1411 let verified = resolve_inputs_with_progress(spec, progress)?;
1412 input_resolution.include(verified.timings);
1413 if fingerprint != verified.fingerprint {
1414 return Err(WasmBuildError::InputsChangedDuringBuild {
1415 before: fingerprint,
1416 after: verified.fingerprint,
1417 });
1418 }
1419
1420 let cached_artifacts = expected_artifacts(spec, &cache_entry);
1421 if cargo_target_dir != cache_entry {
1422 copy_wasm_artifacts(&built_artifacts, &cached_artifacts)?;
1423 }
1424 publish_artifact_stamps(&cached_artifacts, fingerprint)?;
1425 materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
1426 record_cache_entry_use(&cache_entry)?;
1427
1428 Ok(WasmBuildOutcome::Built(complete_build_record(
1429 spec,
1430 BuildRecordInput {
1431 fingerprint,
1432 input_digest: resolved.input_digest,
1433 artifacts,
1434 lock_wait,
1435 shared_incremental_lock_wait,
1436 input_resolution,
1437 cargo_build: Some(cargo_build),
1438 active_entry: &cache_entry,
1439 },
1440 total_started,
1441 )))
1442 })();
1443 finish_fingerprint_build(build_result, incomplete_directory)
1444}
1445
1446pub fn prune_wasm_build_cache(
1454 target_dir: &Path,
1455 policy: WasmBuildCachePrunePolicy,
1456) -> Result<WasmBuildCachePruneReport, WasmBuildError> {
1457 let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
1458 ensure_cache_directory_tag(target_dir)?;
1459
1460 prune_wasm_build_cache_locked(target_dir, policy, None)
1461}
1462
1463struct BuildRecordInput<'a> {
1464 fingerprint: InputDigest,
1465 input_digest: InputDigest,
1466 artifacts: Vec<PathBuf>,
1467 lock_wait: Duration,
1468 shared_incremental_lock_wait: Option<Duration>,
1469 input_resolution: WasmInputResolutionTimings,
1470 cargo_build: Option<Duration>,
1471 active_entry: &'a Path,
1472}
1473
1474fn complete_build_record(
1475 spec: &WasmBuildSpec,
1476 input: BuildRecordInput<'_>,
1477 total_started: Instant,
1478) -> WasmBuildRecord {
1479 let (maintenance, cache_maintenance) = spec.prune_policy.map_or((None, None), |policy| {
1480 let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
1481 let identity = policy.maintenance_identity();
1482 perform_scheduled_cache_maintenance(&cache_root, spec.prune_interval, &identity, || {
1483 prune_wasm_build_cache_locked(&spec.target_dir, policy, Some(input.active_entry))
1484 .map_err(|error| error.to_string())
1485 })
1486 });
1487 WasmBuildRecord {
1488 fingerprint: input.fingerprint,
1489 input_digest: input.input_digest,
1490 artifacts: input.artifacts,
1491 timings: WasmBuildTimings {
1492 lock_wait: input.lock_wait,
1493 shared_incremental_lock_wait: input.shared_incremental_lock_wait,
1494 input_resolution: input.input_resolution,
1495 cargo_build: input.cargo_build,
1496 cache_maintenance,
1497 total: total_started.elapsed(),
1498 },
1499 maintenance,
1500 }
1501}
1502
1503fn prune_wasm_build_cache_locked(
1504 target_dir: &Path,
1505 policy: WasmBuildCachePrunePolicy,
1506 protected_entry: Option<&Path>,
1507) -> Result<WasmBuildCachePruneReport, WasmBuildError> {
1508 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
1509 prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
1510 .map_err(wasm_cache_fs_error)
1511}
1512
1513struct IncompleteBuildDirectory {
1514 path: PathBuf,
1515 armed: bool,
1516}
1517
1518impl IncompleteBuildDirectory {
1519 const fn new(path: PathBuf) -> Self {
1520 Self { path, armed: true }
1521 }
1522
1523 fn preserve(mut self) {
1524 self.armed = false;
1525 }
1526
1527 fn cleanup(mut self) -> io::Result<()> {
1528 let result = remove_path_if_present(&self.path);
1529 if result.is_ok() {
1530 self.armed = false;
1531 }
1532 result
1533 }
1534}
1535
1536impl Drop for IncompleteBuildDirectory {
1537 fn drop(&mut self) {
1538 if self.armed {
1539 let _ = remove_path_if_present(&self.path);
1540 }
1541 }
1542}
1543
1544fn finish_fingerprint_build(
1545 result: Result<WasmBuildOutcome, WasmBuildError>,
1546 incomplete_directory: IncompleteBuildDirectory,
1547) -> Result<WasmBuildOutcome, WasmBuildError> {
1548 match result {
1549 Ok(outcome) => {
1550 incomplete_directory.preserve();
1551 Ok(outcome)
1552 }
1553 Err(build_error) => {
1554 let path = incomplete_directory.path.clone();
1555 match incomplete_directory.cleanup() {
1556 Ok(()) => Err(build_error),
1557 Err(source) => Err(WasmBuildError::FailedBuildCleanup {
1558 build_error: Box::new(build_error),
1559 path,
1560 source,
1561 }),
1562 }
1563 }
1564 }
1565}
1566
1567fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
1568 create_dir_all(target_dir, "create Cargo target directory")?;
1569 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
1570 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)
1571}
1572
1573fn lock_shared_incremental_target(
1574 spec: &WasmBuildSpec,
1575) -> Result<(File, Duration, PathBuf), WasmBuildError> {
1576 let target_dir =
1577 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
1578 message: "shared incremental target is not configured".to_owned(),
1579 })?;
1580 create_dir_all(
1581 &target_dir,
1582 "create shared incremental Cargo target directory",
1583 )?;
1584 ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
1585 let canonical = target_dir
1586 .canonicalize()
1587 .map_err(|source| WasmBuildError::Io {
1588 operation: "resolve shared incremental Cargo target directory",
1589 path: target_dir.clone(),
1590 source,
1591 })?;
1592 let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
1593 let (lock, wait) = lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)?;
1594 Ok((lock, wait, canonical))
1595}
1596
1597fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
1598 ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
1599}
1600
1601fn record_cache_entry_use_if_present(path: &Path) -> Result<(), WasmBuildError> {
1602 if path.is_dir() {
1603 record_cache_entry_use(path)?;
1604 }
1605 Ok(())
1606}
1607
1608fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
1609 record_entry_use(path).map_err(wasm_cache_fs_error)
1610}
1611
1612fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
1613 WasmBuildError::Io {
1614 operation: error.operation,
1615 path: error.path,
1616 source: error.source,
1617 }
1618}
1619
1620fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
1621 if spec.packages.is_empty() {
1622 return Err(WasmBuildError::InvalidSpec {
1623 message: "at least one Cargo package is required".to_owned(),
1624 });
1625 }
1626 if spec.profile_target_dir.is_empty() {
1627 return Err(WasmBuildError::InvalidSpec {
1628 message: "Cargo profile target directory must not be empty".to_owned(),
1629 });
1630 }
1631 if spec.target.is_empty() {
1632 return Err(WasmBuildError::InvalidSpec {
1633 message: "Cargo compilation target must not be empty".to_owned(),
1634 });
1635 }
1636 if matches!(
1637 &spec.cache_mode,
1638 WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
1639 ) {
1640 return Err(WasmBuildError::InvalidSpec {
1641 message: "shared incremental Cargo target directory must not be empty".to_owned(),
1642 });
1643 }
1644 Ok(())
1645}
1646
1647fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1648 let total_started = Instant::now();
1649 let tool_started = Instant::now();
1650 let cargo_identity = command_identity(
1651 spec,
1652 WasmBuildPhase::CargoIdentity,
1653 &spec.cargo_program,
1654 &["--version", "--verbose"],
1655 )?;
1656 let rustc_program = spec
1657 .extra_env
1658 .get(OsStr::new("RUSTC"))
1659 .unwrap_or(&spec.rustc_program);
1660 let rustc_identity =
1661 command_identity(spec, WasmBuildPhase::RustcIdentity, rustc_program, &["-vV"])?;
1662 let tool_identity = tool_started.elapsed();
1663
1664 let metadata_started = Instant::now();
1665 let metadata = cargo_metadata(spec)?;
1666 let cargo_metadata = metadata_started.elapsed();
1667
1668 let discovery_started = Instant::now();
1669 let inputs = resolve_local_inputs(spec, &metadata)?;
1670 validate_shared_incremental_target_boundary(spec, &inputs)?;
1671 let exclusions = source_exclusions(spec, &inputs);
1672 let input_discovery = discovery_started.elapsed();
1673
1674 let hashing_started = Instant::now();
1675 let input_digest = digest_labeled_paths("wasm-source-inputs-v1", &inputs, &exclusions)
1676 .map_err(|source| WasmBuildError::Io {
1677 operation: "hash Wasm build inputs",
1678 path: spec.workspace_root.clone(),
1679 source,
1680 })?;
1681 let content_hashing = hashing_started.elapsed();
1682
1683 let mut hasher = InputHasher::new(CACHE_FORMAT_VERSION);
1684 let mut packages = spec.packages.clone();
1685 packages.sort();
1686 packages.dedup();
1687 for package in packages {
1688 hasher.field("package", package.as_bytes());
1689 }
1690 hasher.field("target", spec.target.as_bytes());
1691 hasher.field("profile-target-dir", spec.profile_target_dir.as_bytes());
1692 for argument in &spec.cargo_profile_args {
1693 hasher.field("cargo-argument", &os_bytes(argument));
1694 }
1695 for (key, value) in effective_environment(spec) {
1696 hasher.field("environment-key", &os_bytes(&key));
1697 if let Some(value) = value {
1698 hasher.field("environment-value", &os_bytes(&value));
1699 } else {
1700 hasher.field("environment-unset", b"");
1701 }
1702 }
1703 hasher.field("cargo-identity", &cargo_identity);
1704 hasher.field("rustc-identity", &rustc_identity);
1705 hasher.field("source-input-digest", input_digest.as_bytes());
1706 Ok(ResolvedCargoBuildInputs {
1707 fingerprint: hasher.finish(),
1708 input_digest,
1709 inputs: inputs
1710 .into_iter()
1711 .map(|(label, path)| CargoBuildInput { label, path })
1712 .collect(),
1713 exclusions,
1714 timings: WasmInputResolutionTimings {
1715 tool_identity,
1716 cargo_metadata,
1717 input_discovery,
1718 content_hashing,
1719 total: total_started.elapsed(),
1720 },
1721 })
1722}
1723
1724fn command_identity(
1725 spec: &WasmBuildSpec,
1726 phase: WasmBuildPhase,
1727 program: &OsStr,
1728 arguments: &[&str],
1729) -> Result<Vec<u8>, WasmBuildError> {
1730 let mut command = Command::new(program);
1731 command.current_dir(&spec.workspace_root).args(arguments);
1732 apply_command_environment(&mut command, spec);
1733 let output = command
1734 .output()
1735 .map_err(|source| WasmBuildError::CommandSpawn {
1736 phase,
1737 program: program.to_owned(),
1738 source,
1739 })?;
1740 ensure_command_success(phase, output).map(|output| {
1741 let mut identity = output.stdout;
1742 identity.extend_from_slice(&output.stderr);
1743 identity
1744 })
1745}
1746
1747fn cargo_metadata(spec: &WasmBuildSpec) -> Result<Value, WasmBuildError> {
1748 let mut command = Command::new(&spec.cargo_program);
1749 command
1750 .current_dir(&spec.workspace_root)
1751 .args(["metadata", "--format-version", "1"]);
1752 for argument in metadata_arguments(&spec.cargo_profile_args) {
1753 command.arg(argument);
1754 }
1755 apply_command_environment(&mut command, spec);
1756 let output = command
1757 .output()
1758 .map_err(|source| WasmBuildError::CommandSpawn {
1759 phase: WasmBuildPhase::CargoMetadata,
1760 program: spec.cargo_program.clone(),
1761 source,
1762 })?;
1763 let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
1764 serde_json::from_slice(&output.stdout).map_err(|error| WasmBuildError::InvalidMetadata {
1765 message: format!("Cargo metadata was not valid JSON: {error}"),
1766 })
1767}
1768
1769fn metadata_arguments(arguments: &[OsString]) -> Vec<OsString> {
1770 let mut selected = Vec::new();
1771 let mut arguments = arguments.iter();
1772 while let Some(argument) = arguments.next() {
1773 let argument_text = argument.to_string_lossy();
1774 match argument_text.as_ref() {
1775 "--all-features" | "--no-default-features" | "--locked" | "--offline" | "--frozen" => {
1776 selected.push(argument.clone());
1777 }
1778 "--features" | "-F" | "--filter-platform" => {
1779 selected.push(argument.clone());
1780 if let Some(value) = arguments.next() {
1781 selected.push(value.clone());
1782 }
1783 }
1784 _ if argument_text.starts_with("--features=")
1785 || argument_text.starts_with("--filter-platform=") =>
1786 {
1787 selected.push(argument.clone());
1788 }
1789 _ => {}
1790 }
1791 }
1792 selected
1793}
1794
1795#[derive(Clone)]
1796struct MetadataPackage {
1797 id: String,
1798 name: String,
1799 version: String,
1800 manifest_path: PathBuf,
1801 is_local: bool,
1802}
1803
1804fn resolve_local_inputs(
1805 spec: &WasmBuildSpec,
1806 metadata: &Value,
1807) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
1808 let packages = metadata_packages(metadata)?;
1809 let mut selected_ids = selected_package_ids(spec, metadata, &packages)?;
1810 let dependencies = metadata_dependencies(metadata)?;
1811 let mut closure = BTreeSet::new();
1812 while let Some(id) = selected_ids.pop_front() {
1813 if !closure.insert(id.clone()) {
1814 continue;
1815 }
1816 if let Some(deps) = dependencies.get(&id) {
1817 selected_ids.extend(deps.iter().cloned());
1818 }
1819 }
1820
1821 let workspace_root = metadata
1822 .get("workspace_root")
1823 .and_then(Value::as_str)
1824 .map_or_else(|| spec.workspace_root.clone(), PathBuf::from);
1825 let mut inputs = workspace_configuration_inputs(spec, &workspace_root)?;
1826 append_package_inputs(&mut inputs, &packages, closure, &workspace_root)?;
1827 append_additional_inputs(&mut inputs, spec, &workspace_root);
1828 Ok(inputs)
1829}
1830
1831fn metadata_packages(metadata: &Value) -> Result<HashMap<String, MetadataPackage>, WasmBuildError> {
1832 let packages_value = metadata
1833 .get("packages")
1834 .and_then(Value::as_array)
1835 .ok_or_else(|| invalid_metadata("Cargo metadata has no package array"))?;
1836 let mut packages = HashMap::new();
1837 for value in packages_value {
1838 let package = MetadataPackage {
1839 id: required_string(value, "id")?,
1840 name: required_string(value, "name")?,
1841 version: required_string(value, "version")?,
1842 manifest_path: PathBuf::from(required_string(value, "manifest_path")?),
1843 is_local: value.get("source").is_some_and(Value::is_null),
1844 };
1845 packages.insert(package.id.clone(), package);
1846 }
1847 Ok(packages)
1848}
1849
1850fn selected_package_ids(
1851 spec: &WasmBuildSpec,
1852 metadata: &Value,
1853 packages: &HashMap<String, MetadataPackage>,
1854) -> Result<VecDeque<String>, WasmBuildError> {
1855 let workspace_members = metadata
1856 .get("workspace_members")
1857 .and_then(Value::as_array)
1858 .ok_or_else(|| invalid_metadata("Cargo metadata has no workspace member array"))?
1859 .iter()
1860 .filter_map(Value::as_str)
1861 .collect::<HashSet<_>>();
1862 let mut selected_ids = VecDeque::new();
1863 for requested in &spec.packages {
1864 let matches = packages
1865 .values()
1866 .filter(|package| {
1867 package.name == *requested && workspace_members.contains(package.id.as_str())
1868 })
1869 .map(|package| package.id.clone())
1870 .collect::<Vec<_>>();
1871 match matches.as_slice() {
1872 [id] => selected_ids.push_back(id.clone()),
1873 [] => {
1874 return Err(WasmBuildError::InvalidSpec {
1875 message: format!("Cargo workspace contains no package named `{requested}`"),
1876 });
1877 }
1878 _ => {
1879 return Err(WasmBuildError::InvalidSpec {
1880 message: format!("Cargo workspace package name `{requested}` is ambiguous"),
1881 });
1882 }
1883 }
1884 }
1885 Ok(selected_ids)
1886}
1887
1888fn metadata_dependencies(metadata: &Value) -> Result<HashMap<String, Vec<String>>, WasmBuildError> {
1889 let mut dependencies = HashMap::<String, Vec<String>>::new();
1890 let nodes = metadata
1891 .pointer("/resolve/nodes")
1892 .and_then(Value::as_array)
1893 .ok_or_else(|| invalid_metadata("Cargo metadata has no resolved dependency nodes"))?;
1894 for node in nodes {
1895 let id = required_string(node, "id")?;
1896 let deps = node
1897 .get("deps")
1898 .and_then(Value::as_array)
1899 .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
1900 .iter()
1901 .map(|dependency| required_string(dependency, "pkg"))
1902 .collect::<Result<Vec<_>, _>>()?;
1903 dependencies.insert(id, deps);
1904 }
1905 Ok(dependencies)
1906}
1907
1908fn workspace_configuration_inputs(
1909 spec: &WasmBuildSpec,
1910 workspace_root: &Path,
1911) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
1912 let mut inputs = Vec::new();
1913 add_if_present(
1914 &mut inputs,
1915 "workspace/Cargo.toml",
1916 workspace_root.join("Cargo.toml"),
1917 );
1918 add_if_present(
1919 &mut inputs,
1920 "workspace/Cargo.lock",
1921 workspace_root.join("Cargo.lock"),
1922 );
1923 add_if_present(
1924 &mut inputs,
1925 "workspace/rust-toolchain.toml",
1926 workspace_root.join("rust-toolchain.toml"),
1927 );
1928 add_if_present(
1929 &mut inputs,
1930 "workspace/rust-toolchain",
1931 workspace_root.join("rust-toolchain"),
1932 );
1933 append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
1934 Ok(inputs)
1935}
1936
1937fn append_cargo_configuration_inputs(
1938 inputs: &mut Vec<(PathBuf, PathBuf)>,
1939 spec: &WasmBuildSpec,
1940 workspace_root: &Path,
1941) -> Result<(), WasmBuildError> {
1942 let invocation_root =
1943 spec.workspace_root
1944 .canonicalize()
1945 .map_err(|source| WasmBuildError::Io {
1946 operation: "resolve Cargo invocation directory",
1947 path: spec.workspace_root.clone(),
1948 source,
1949 })?;
1950 let canonical_workspace =
1951 workspace_root
1952 .canonicalize()
1953 .map_err(|source| WasmBuildError::Io {
1954 operation: "resolve Cargo workspace directory",
1955 path: workspace_root.to_owned(),
1956 source,
1957 })?;
1958
1959 let mut roots = invocation_root
1960 .ancestors()
1961 .filter_map(|directory| effective_cargo_config(&directory.join(".cargo")))
1962 .collect::<Vec<_>>();
1963 if let Some(cargo_home) = effective_cargo_home(spec, &invocation_root)
1964 && let Some(config) = effective_cargo_config(&cargo_home)
1965 {
1966 roots.push(config);
1967 }
1968
1969 let mut visited = BTreeSet::new();
1970 for config in roots {
1971 append_cargo_configuration_tree(
1972 inputs,
1973 &config,
1974 &canonical_workspace,
1975 &mut visited,
1976 false,
1977 )?;
1978 }
1979 Ok(())
1980}
1981
1982fn effective_cargo_config(directory: &Path) -> Option<PathBuf> {
1983 let extensionless = directory.join("config");
1984 if extensionless.exists() {
1985 return Some(extensionless);
1986 }
1987 let toml = directory.join("config.toml");
1988 toml.exists().then_some(toml)
1989}
1990
1991fn effective_cargo_home(spec: &WasmBuildSpec, invocation_root: &Path) -> Option<PathBuf> {
1992 if let Some(cargo_home) = command_environment_value(spec, "CARGO_HOME") {
1993 let cargo_home = PathBuf::from(cargo_home);
1994 return Some(if cargo_home.is_absolute() {
1995 cargo_home
1996 } else {
1997 invocation_root.join(cargo_home)
1998 });
1999 }
2000
2001 default_home_directory(spec).map(|home| {
2002 let home = if home.is_absolute() {
2003 home
2004 } else {
2005 invocation_root.join(home)
2006 };
2007 home.join(".cargo")
2008 })
2009}
2010
2011#[cfg(windows)]
2012fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
2013 command_environment_value(spec, "USERPROFILE")
2014 .or_else(|| command_environment_value(spec, "HOME"))
2015 .map(PathBuf::from)
2016}
2017
2018#[cfg(not(windows))]
2019fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
2020 command_environment_value(spec, "HOME").map(PathBuf::from)
2021}
2022
2023fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
2024 spec.extra_env
2025 .get(OsStr::new(name))
2026 .cloned()
2027 .or_else(|| std::env::var_os(name))
2028}
2029
2030fn append_cargo_configuration_tree(
2031 inputs: &mut Vec<(PathBuf, PathBuf)>,
2032 config: &Path,
2033 workspace_root: &Path,
2034 visited: &mut BTreeSet<PathBuf>,
2035 optional: bool,
2036) -> Result<(), WasmBuildError> {
2037 let canonical = match config.canonicalize() {
2038 Ok(canonical) => canonical,
2039 Err(error) if optional && error.kind() == io::ErrorKind::NotFound => return Ok(()),
2040 Err(source) => {
2041 return Err(WasmBuildError::Io {
2042 operation: "resolve Cargo configuration",
2043 path: config.to_owned(),
2044 source,
2045 });
2046 }
2047 };
2048 if !visited.insert(canonical.clone()) {
2049 return Ok(());
2050 }
2051
2052 let contents = fs::read_to_string(&canonical).map_err(|source| WasmBuildError::Io {
2053 operation: "read Cargo configuration",
2054 path: canonical.clone(),
2055 source,
2056 })?;
2057 let configuration = toml::from_str::<TomlValue>(&contents).map_err(|error| {
2058 WasmBuildError::InvalidCargoConfiguration {
2059 path: canonical.clone(),
2060 message: error.to_string(),
2061 }
2062 })?;
2063 inputs.push((
2064 cargo_configuration_label(&canonical, workspace_root),
2065 canonical.clone(),
2066 ));
2067
2068 let Some(include) = configuration.get("include") else {
2069 return Ok(());
2070 };
2071 let parent = canonical
2072 .parent()
2073 .ok_or_else(|| WasmBuildError::InvalidCargoConfiguration {
2074 path: canonical.clone(),
2075 message: "configuration path has no parent directory".to_owned(),
2076 })?;
2077 for (included, optional) in cargo_configuration_includes(include, &canonical)? {
2078 let included = if included.is_absolute() {
2079 included
2080 } else {
2081 parent.join(included)
2082 };
2083 append_cargo_configuration_tree(inputs, &included, workspace_root, visited, optional)?;
2084 }
2085 Ok(())
2086}
2087
2088fn cargo_configuration_includes(
2089 include: &TomlValue,
2090 config: &Path,
2091) -> Result<Vec<(PathBuf, bool)>, WasmBuildError> {
2092 let values = match include {
2093 TomlValue::Array(values) => values.as_slice(),
2094 value => std::slice::from_ref(value),
2095 };
2096 values
2097 .iter()
2098 .map(|value| match value {
2099 TomlValue::String(path) => Ok((PathBuf::from(path), false)),
2100 TomlValue::Table(table) => {
2101 let path = table
2102 .get("path")
2103 .and_then(TomlValue::as_str)
2104 .ok_or_else(|| {
2105 invalid_cargo_configuration(
2106 config,
2107 "Cargo configuration include table requires a string `path`",
2108 )
2109 })?;
2110 let optional = table
2111 .get("optional")
2112 .map(|value| {
2113 value.as_bool().ok_or_else(|| {
2114 invalid_cargo_configuration(
2115 config,
2116 "Cargo configuration include `optional` must be a boolean",
2117 )
2118 })
2119 })
2120 .transpose()?
2121 .unwrap_or(false);
2122 Ok((PathBuf::from(path), optional))
2123 }
2124 _ => Err(invalid_cargo_configuration(
2125 config,
2126 "Cargo configuration `include` must contain paths or include tables",
2127 )),
2128 })
2129 .collect()
2130}
2131
2132fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
2133 if let Ok(relative) = config.strip_prefix(workspace_root) {
2134 return PathBuf::from("cargo-config/workspace").join(relative);
2135 }
2136 let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
2137 PathBuf::from("cargo-config/external").join(location.to_hex())
2138}
2139
2140fn invalid_cargo_configuration(path: &Path, message: &str) -> WasmBuildError {
2141 WasmBuildError::InvalidCargoConfiguration {
2142 path: path.to_owned(),
2143 message: message.to_owned(),
2144 }
2145}
2146
2147fn append_package_inputs(
2148 inputs: &mut Vec<(PathBuf, PathBuf)>,
2149 packages: &HashMap<String, MetadataPackage>,
2150 closure: BTreeSet<String>,
2151 workspace_root: &Path,
2152) -> Result<(), WasmBuildError> {
2153 for id in closure {
2154 let Some(package) = packages.get(&id) else {
2155 return Err(invalid_metadata(&format!(
2156 "resolved package `{id}` is missing"
2157 )));
2158 };
2159 if !package.is_local {
2160 continue;
2161 }
2162 let root = package.manifest_path.parent().ok_or_else(|| {
2163 invalid_metadata(&format!(
2164 "package `{}` manifest has no parent",
2165 package.name
2166 ))
2167 })?;
2168 let relative_manifest = package
2169 .manifest_path
2170 .strip_prefix(workspace_root)
2171 .unwrap_or(&package.manifest_path);
2172 let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
2173 .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
2174 inputs.push((label, root.to_owned()));
2175 }
2176 Ok(())
2177}
2178
2179fn append_additional_inputs(
2180 inputs: &mut Vec<(PathBuf, PathBuf)>,
2181 spec: &WasmBuildSpec,
2182 workspace_root: &Path,
2183) {
2184 for additional in &spec.additional_inputs {
2185 let path = if additional.is_absolute() {
2186 additional.clone()
2187 } else {
2188 workspace_root.join(additional)
2189 };
2190 inputs.push((PathBuf::from("additional").join(additional), path));
2191 }
2192}
2193
2194fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
2195 let mut exclusions = vec![
2196 spec.target_dir.clone(),
2197 spec.workspace_root.join("target"),
2198 spec.workspace_root.join(".git"),
2199 ];
2200 if let Some(shared_target) = shared_incremental_target(spec) {
2201 exclusions.push(shared_target);
2202 }
2203 for (_, path) in inputs {
2204 if path.is_dir() {
2205 exclusions.push(path.join("target"));
2206 exclusions.push(path.join(".git"));
2207 }
2208 }
2209 exclusions
2210}
2211
2212fn validate_shared_incremental_target_boundary(
2213 spec: &WasmBuildSpec,
2214 inputs: &[(PathBuf, PathBuf)],
2215) -> Result<(), WasmBuildError> {
2216 let Some(shared_target) = shared_incremental_target(spec) else {
2217 return Ok(());
2218 };
2219 let shared_target =
2220 canonicalize_allow_missing(&shared_target).map_err(|source| WasmBuildError::Io {
2221 operation: "resolve shared incremental Cargo target boundary",
2222 path: shared_target.clone(),
2223 source,
2224 })?;
2225 let resolved_inputs = inputs
2226 .iter()
2227 .map(|(_, input)| {
2228 let canonical = input.canonicalize().map_err(|source| WasmBuildError::Io {
2229 operation: "resolve Cargo input boundary",
2230 path: input.clone(),
2231 source,
2232 })?;
2233 let metadata = fs::metadata(&canonical).map_err(|source| WasmBuildError::Io {
2234 operation: "inspect Cargo input boundary",
2235 path: canonical.clone(),
2236 source,
2237 })?;
2238 Ok((canonical, metadata.is_dir()))
2239 })
2240 .collect::<Result<Vec<_>, WasmBuildError>>()?;
2241 let safe_generated_roots = std::iter::once(spec.target_dir.clone())
2242 .chain(std::iter::once(spec.workspace_root.join("target")))
2243 .chain(
2244 inputs
2245 .iter()
2246 .filter(|(_, path)| path.is_dir())
2247 .map(|(_, path)| path.join("target")),
2248 )
2249 .filter_map(|path| canonicalize_allow_missing(&path).ok())
2250 .filter(|root| {
2251 !resolved_inputs
2252 .iter()
2253 .any(|(input, _is_directory)| input.starts_with(root))
2254 })
2255 .collect::<Vec<_>>();
2256 if safe_generated_roots
2257 .iter()
2258 .any(|root| shared_target.starts_with(root))
2259 {
2260 return Ok(());
2261 }
2262
2263 for (input, is_directory) in resolved_inputs {
2264 if shared_target == input
2265 || (is_directory && shared_target.starts_with(&input))
2266 || input.starts_with(&shared_target)
2267 {
2268 return Err(WasmBuildError::InvalidSpec {
2269 message: format!(
2270 "shared incremental target {} must not overlap exact Cargo inputs unless it is inside a generated target directory",
2271 shared_target.display()
2272 ),
2273 });
2274 }
2275 }
2276 Ok(())
2277}
2278
2279fn canonicalize_allow_missing(path: &Path) -> io::Result<PathBuf> {
2280 let absolute = if path.is_absolute() {
2281 path.to_owned()
2282 } else {
2283 std::env::current_dir()?.join(path)
2284 };
2285 let mut unresolved = Vec::<OsString>::new();
2286 let mut existing = absolute.as_path();
2287 loop {
2288 match existing.canonicalize() {
2289 Ok(mut canonical) => {
2290 for component in unresolved.into_iter().rev() {
2291 canonical.push(component);
2292 }
2293 return Ok(canonical);
2294 }
2295 Err(error) if error.kind() == io::ErrorKind::NotFound => {
2296 let Some(name) = existing.file_name() else {
2297 return Err(error);
2298 };
2299 unresolved.push(name.to_owned());
2300 existing = existing.parent().ok_or(error)?;
2301 }
2302 Err(error) => return Err(error),
2303 }
2304 }
2305}
2306
2307fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
2308 let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
2309 return None;
2310 };
2311 Some(if target_dir.is_absolute() {
2312 target_dir.clone()
2313 } else {
2314 spec.workspace_root.join(target_dir)
2315 })
2316}
2317
2318fn effective_environment(spec: &WasmBuildSpec) -> BTreeMap<OsString, Option<OsString>> {
2319 let mut names = spec.inherited_env.clone();
2320 names.extend(AUTOMATIC_ENVIRONMENT.iter().map(OsString::from));
2321 let mut environment = names
2322 .into_iter()
2323 .map(|name| {
2324 let value = std::env::var_os(&name);
2325 (name, value)
2326 })
2327 .collect::<BTreeMap<_, _>>();
2328 for (key, value) in &spec.extra_env {
2329 environment.insert(key.clone(), Some(value.clone()));
2330 }
2331 environment
2332}
2333
2334fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
2335 for (key, value) in &spec.extra_env {
2336 command.env(key, value);
2337 }
2338}
2339
2340fn run_cargo_build(
2341 spec: &WasmBuildSpec,
2342 build_target_dir: &Path,
2343 progress: &mut ProgressReporter<'_>,
2344) -> Result<(), WasmBuildError> {
2345 let mut command = Command::new(&spec.cargo_program);
2346 command
2347 .current_dir(&spec.workspace_root)
2348 .env("CARGO_TARGET_DIR", build_target_dir)
2349 .args(["build", "--target", &spec.target])
2350 .args(&spec.cargo_profile_args);
2351 apply_command_environment(&mut command, spec);
2352 for package in &spec.packages {
2353 command.args(["-p", package]);
2354 }
2355
2356 if !progress.is_observed() {
2357 let output = command
2358 .output()
2359 .map_err(|source| WasmBuildError::CommandSpawn {
2360 phase: WasmBuildPhase::CargoBuild,
2361 program: spec.cargo_program.clone(),
2362 source,
2363 })?;
2364 return ensure_command_success(WasmBuildPhase::CargoBuild, output).map(|_| ());
2365 }
2366
2367 run_observed_cargo_build(spec, build_target_dir, command, progress)
2368}
2369
2370fn run_observed_cargo_build(
2371 spec: &WasmBuildSpec,
2372 build_target_dir: &Path,
2373 mut command: Command,
2374 progress: &mut ProgressReporter<'_>,
2375) -> Result<(), WasmBuildError> {
2376 command.stdout(Stdio::piped()).stderr(Stdio::piped());
2377 let started = Instant::now();
2378 let child = command
2379 .spawn()
2380 .map_err(|source| WasmBuildError::CommandSpawn {
2381 phase: WasmBuildPhase::CargoBuild,
2382 program: spec.cargo_program.clone(),
2383 source,
2384 })?;
2385 let mut child = ObservedChild::new(child);
2386 progress.emit(WasmBuildProgressEvent::CargoStarted {
2387 target_dir: build_target_dir.to_owned(),
2388 });
2389
2390 let stdout = child
2391 .child_mut()
2392 .stdout
2393 .take()
2394 .expect("Cargo stdout must be piped");
2395 let stderr = child
2396 .child_mut()
2397 .stderr
2398 .take()
2399 .expect("Cargo stderr must be piped");
2400 let (sender, chunks) = mpsc::channel();
2401 let stdout_sender = sender.clone();
2402 let stdout_reader = thread::spawn(move || {
2403 read_process_output(stdout, WasmBuildOutputStream::Stdout, stdout_sender)
2404 });
2405 let stderr_reader =
2406 thread::spawn(move || read_process_output(stderr, WasmBuildOutputStream::Stderr, sender));
2407
2408 let captured = capture_observed_cargo_output(chunks, progress, started);
2409
2410 let status = child.wait().map_err(|source| WasmBuildError::Io {
2411 operation: "wait for observed cargo build",
2412 path: PathBuf::from(&spec.cargo_program),
2413 source,
2414 })?;
2415 join_output_reader(
2416 stdout_reader,
2417 "read observed cargo stdout",
2418 &spec.cargo_program,
2419 )?;
2420 join_output_reader(
2421 stderr_reader,
2422 "read observed cargo stderr",
2423 &spec.cargo_program,
2424 )?;
2425 let elapsed = started.elapsed();
2426 progress.emit(WasmBuildProgressEvent::CargoFinished {
2427 success: status.success(),
2428 code: status.code(),
2429 elapsed,
2430 });
2431
2432 ensure_command_success(
2433 WasmBuildPhase::CargoBuild,
2434 Output {
2435 status,
2436 stdout: captured.stdout,
2437 stderr: captured.stderr,
2438 },
2439 )
2440 .map(|_| ())
2441}
2442
2443struct CapturedProcessOutput {
2444 stdout: Vec<u8>,
2445 stderr: Vec<u8>,
2446}
2447
2448fn capture_observed_cargo_output(
2449 chunks: mpsc::Receiver<ProcessOutputChunk>,
2450 progress: &mut ProgressReporter<'_>,
2451 started: Instant,
2452) -> CapturedProcessOutput {
2453 let mut stdout = Vec::new();
2454 let mut stderr = Vec::new();
2455 let mut last_emitted = Instant::now();
2456 loop {
2457 let message = match progress.config.heartbeat_interval {
2458 Some(interval) => {
2459 let quiet_for = last_emitted.elapsed();
2460 if quiet_for >= interval {
2461 progress.emit(WasmBuildProgressEvent::CargoHeartbeat {
2462 elapsed: started.elapsed(),
2463 });
2464 last_emitted = Instant::now();
2465 None
2466 } else {
2467 match chunks.recv_timeout(interval.saturating_sub(quiet_for)) {
2468 Ok(chunk) => Some(chunk),
2469 Err(RecvTimeoutError::Timeout) => {
2470 progress.emit(WasmBuildProgressEvent::CargoHeartbeat {
2471 elapsed: started.elapsed(),
2472 });
2473 last_emitted = Instant::now();
2474 None
2475 }
2476 Err(RecvTimeoutError::Disconnected) => break,
2477 }
2478 }
2479 }
2480 None => match chunks.recv() {
2481 Ok(chunk) => Some(chunk),
2482 Err(_) => break,
2483 },
2484 };
2485 let Some(chunk) = message else {
2486 continue;
2487 };
2488 match chunk.stream {
2489 WasmBuildOutputStream::Stdout => stdout.extend_from_slice(&chunk.bytes),
2490 WasmBuildOutputStream::Stderr => stderr.extend_from_slice(&chunk.bytes),
2491 }
2492 if progress.config.emit_cargo_output {
2493 progress.emit(WasmBuildProgressEvent::CargoOutput {
2494 stream: chunk.stream,
2495 bytes: chunk.bytes,
2496 });
2497 last_emitted = Instant::now();
2498 }
2499 }
2500 CapturedProcessOutput { stdout, stderr }
2501}
2502
2503#[derive(Debug)]
2504struct ProcessOutputChunk {
2505 stream: WasmBuildOutputStream,
2506 bytes: Vec<u8>,
2507}
2508
2509fn read_process_output<R: io::Read>(
2510 mut reader: R,
2511 stream: WasmBuildOutputStream,
2512 sender: mpsc::Sender<ProcessOutputChunk>,
2513) -> io::Result<()> {
2514 let mut buffer = [0_u8; 8 * 1024];
2515 loop {
2516 let count = reader.read(&mut buffer)?;
2517 if count == 0 {
2518 return Ok(());
2519 }
2520 if sender
2521 .send(ProcessOutputChunk {
2522 stream,
2523 bytes: buffer[..count].to_vec(),
2524 })
2525 .is_err()
2526 {
2527 return Ok(());
2528 }
2529 }
2530}
2531
2532fn join_output_reader(
2533 reader: thread::JoinHandle<io::Result<()>>,
2534 operation: &'static str,
2535 cargo_program: &OsStr,
2536) -> Result<(), WasmBuildError> {
2537 let result = reader.join().map_err(|_| WasmBuildError::Io {
2538 operation,
2539 path: PathBuf::from(cargo_program),
2540 source: io::Error::other("Cargo output reader panicked"),
2541 })?;
2542 result.map_err(|source| WasmBuildError::Io {
2543 operation,
2544 path: PathBuf::from(cargo_program),
2545 source,
2546 })
2547}
2548
2549struct ObservedChild(Option<Child>);
2550
2551impl ObservedChild {
2552 const fn new(child: Child) -> Self {
2553 Self(Some(child))
2554 }
2555
2556 const fn child_mut(&mut self) -> &mut Child {
2557 self.0.as_mut().expect("observed child must be present")
2558 }
2559
2560 fn wait(&mut self) -> io::Result<ExitStatus> {
2561 let status = self.child_mut().wait()?;
2562 self.0.take();
2563 Ok(status)
2564 }
2565}
2566
2567impl Drop for ObservedChild {
2568 fn drop(&mut self) {
2569 if let Some(mut child) = self.0.take() {
2570 let _ = child.kill();
2571 let _ = child.wait();
2572 }
2573 }
2574}
2575
2576fn ensure_command_success(phase: WasmBuildPhase, output: Output) -> Result<Output, WasmBuildError> {
2577 if output.status.success() {
2578 return Ok(output);
2579 }
2580 Err(WasmBuildError::CommandFailed {
2581 phase,
2582 status: output.status,
2583 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
2584 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
2585 })
2586}
2587
2588fn expected_artifacts(spec: &WasmBuildSpec, target_dir: &Path) -> Vec<PathBuf> {
2589 let mut packages = spec.packages.iter().map(String::as_str).collect::<Vec<_>>();
2590 packages.sort_unstable();
2591 packages.dedup();
2592 packages
2593 .into_iter()
2594 .map(|package| {
2595 if spec.target == DEFAULT_TARGET {
2596 wasm_path(target_dir, package, &spec.profile_target_dir)
2597 } else {
2598 target_dir
2599 .join(&spec.target)
2600 .join(&spec.profile_target_dir)
2601 .join(format!("{package}.wasm"))
2602 }
2603 })
2604 .collect()
2605}
2606
2607fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
2608 spec.target_dir
2609 .join(".ic-testkit/wasm-targets")
2610 .join(fingerprint.to_hex())
2611}
2612
2613fn artifact_set_matches(artifacts: &[PathBuf], fingerprint: InputDigest) -> bool {
2614 artifacts.iter().all(|path| {
2615 fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
2616 && cache_stamp_matches(path, fingerprint)
2617 })
2618}
2619
2620fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
2621 artifacts
2622 .iter()
2623 .filter(|path| {
2624 fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
2625 })
2626 .cloned()
2627 .collect()
2628}
2629
2630fn cache_stamp_matches(artifact: &Path, fingerprint: InputDigest) -> bool {
2631 let stamp_path = artifact_stamp_path(artifact);
2632 let Ok(expected) = artifact_stamp_contents(artifact, fingerprint) else {
2633 return false;
2634 };
2635 fs::read_to_string(stamp_path).is_ok_and(|stamp| stamp == expected)
2636}
2637
2638fn artifact_stamp_path(artifact: &Path) -> PathBuf {
2639 let mut name = artifact
2640 .file_name()
2641 .map_or_else(|| OsString::from("artifact"), OsString::from);
2642 name.push(".ic-testkit-build");
2643 artifact.with_file_name(name)
2644}
2645
2646fn artifact_stamp_contents(artifact: &Path, fingerprint: InputDigest) -> io::Result<String> {
2647 let (_, artifact_digest) = digest_file("wasm-artifact-v1", artifact)?;
2648 Ok(format!(
2649 "{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\nartifact-sha256:{artifact_digest}\n"
2650 ))
2651}
2652
2653fn publish_artifact_stamps(
2654 artifacts: &[PathBuf],
2655 fingerprint: InputDigest,
2656) -> Result<(), WasmBuildError> {
2657 for artifact in artifacts {
2658 let stamp_path = artifact_stamp_path(artifact);
2659 let stamp = artifact_stamp_contents(artifact, fingerprint).map_err(|source| {
2660 WasmBuildError::Io {
2661 operation: "hash built Wasm artifact",
2662 path: artifact.clone(),
2663 source,
2664 }
2665 })?;
2666 write_atomic(&stamp_path, stamp.as_bytes()).map_err(|source| WasmBuildError::Io {
2667 operation: "publish Wasm build stamp",
2668 path: stamp_path,
2669 source,
2670 })?;
2671 }
2672 Ok(())
2673}
2674
2675fn materialize_artifacts(
2676 cached_artifacts: &[PathBuf],
2677 artifacts: &[PathBuf],
2678 fingerprint: InputDigest,
2679) -> Result<(), WasmBuildError> {
2680 for (cached, artifact) in cached_artifacts.iter().zip(artifacts) {
2681 copy_file_atomic(cached, artifact).map_err(|source| WasmBuildError::Io {
2682 operation: "publish Wasm artifact",
2683 path: artifact.clone(),
2684 source,
2685 })?;
2686 }
2687 publish_artifact_stamps(artifacts, fingerprint)
2688}
2689
2690fn copy_wasm_artifacts(
2691 source_artifacts: &[PathBuf],
2692 cached_artifacts: &[PathBuf],
2693) -> Result<(), WasmBuildError> {
2694 for (source, cached) in source_artifacts.iter().zip(cached_artifacts) {
2695 copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
2696 operation: "cache shared-incremental Wasm artifact",
2697 path: cached.clone(),
2698 source: source_error,
2699 })?;
2700 }
2701 Ok(())
2702}
2703
2704fn remove_directory_if_present(path: &Path) -> Result<(), WasmBuildError> {
2705 remove_path_if_present(path).map_err(|source| WasmBuildError::Io {
2706 operation: "remove incomplete content-addressed Cargo target directory",
2707 path: path.to_owned(),
2708 source,
2709 })
2710}
2711
2712fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
2713 fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
2714 operation,
2715 path: path.to_owned(),
2716 source,
2717 })
2718}
2719
2720fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
2721 if path.exists() {
2722 inputs.push((PathBuf::from(label), path));
2723 }
2724}
2725
2726fn required_string(value: &Value, field: &str) -> Result<String, WasmBuildError> {
2727 value
2728 .get(field)
2729 .and_then(Value::as_str)
2730 .map(str::to_owned)
2731 .ok_or_else(|| invalid_metadata(&format!("Cargo metadata field `{field}` is missing")))
2732}
2733
2734fn invalid_metadata(message: &str) -> WasmBuildError {
2735 WasmBuildError::InvalidMetadata {
2736 message: message.to_owned(),
2737 }
2738}
2739
2740impl std::fmt::Display for WasmBuildPhase {
2741 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2742 formatter.write_str(match self {
2743 Self::CargoMetadata => "cargo metadata",
2744 Self::CargoIdentity => "Cargo identity",
2745 Self::RustcIdentity => "Rust compiler identity",
2746 Self::CargoBuild => "cargo build",
2747 })
2748 }
2749}
2750
2751impl std::fmt::Display for WasmBuildError {
2752 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2753 match self {
2754 Self::InvalidSpec { message } => {
2755 write!(formatter, "invalid Wasm build spec: {message}")
2756 }
2757 Self::Io {
2758 operation,
2759 path,
2760 source,
2761 } => write!(
2762 formatter,
2763 "failed to {operation} at {}: {source}",
2764 path.display()
2765 ),
2766 Self::CommandSpawn {
2767 phase,
2768 program,
2769 source,
2770 } => write!(
2771 formatter,
2772 "failed to launch {phase} using `{}`: {source}",
2773 program.to_string_lossy(),
2774 ),
2775 Self::CommandFailed {
2776 phase,
2777 status,
2778 stdout,
2779 stderr,
2780 } => write!(
2781 formatter,
2782 "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
2783 ),
2784 Self::InvalidMetadata { message } => {
2785 write!(formatter, "invalid Cargo metadata: {message}")
2786 }
2787 Self::InvalidCargoConfiguration { path, message } => write!(
2788 formatter,
2789 "invalid Cargo configuration at {}: {message}",
2790 path.display(),
2791 ),
2792 Self::MissingArtifacts { paths } => write!(
2793 formatter,
2794 "cargo build succeeded without producing: {}",
2795 paths
2796 .iter()
2797 .map(|path| path.display().to_string())
2798 .collect::<Vec<_>>()
2799 .join(", "),
2800 ),
2801 Self::InputsChangedDuringBuild { before, after } => write!(
2802 formatter,
2803 "Wasm build inputs changed while Cargo was running: {before} -> {after}",
2804 ),
2805 Self::FailedBuildCleanup {
2806 build_error,
2807 path,
2808 source,
2809 } => write!(
2810 formatter,
2811 "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
2812 path.display(),
2813 ),
2814 }
2815 }
2816}
2817
2818impl std::error::Error for WasmBuildError {
2819 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
2820 match self {
2821 Self::Io { source, .. }
2822 | Self::CommandSpawn { source, .. }
2823 | Self::FailedBuildCleanup { source, .. } => Some(source),
2824 _ => None,
2825 }
2826 }
2827}
2828
2829#[cfg(test)]
2830mod tests {
2831 use super::{
2832 IncompleteBuildDirectory, ProgressReporter, SharedIncrementalTargetPrunePolicy,
2833 WasmBuildCachePrunePolicy, WasmBuildError, WasmBuildOutcome, WasmBuildOutputStream,
2834 WasmBuildProgressConfig, WasmBuildProgressEvent, WasmBuildSpec,
2835 append_cargo_configuration_inputs, build_wasm_canisters_cached_with_progress,
2836 ensure_cache_directory_tag, finish_fingerprint_build, inspect_shared_incremental_target,
2837 maintain_shared_incremental_target, metadata_arguments, prune_wasm_build_cache,
2838 prune_wasm_build_cache_locked, resolve_cargo_build_inputs, run_cargo_build, validate_spec,
2839 };
2840 use crate::artifacts::cache_fs::{
2841 CACHE_DIRECTORY_TAG_SIGNATURE, directory_logical_size, write_last_used,
2842 };
2843 use crate::artifacts::test_support::unique_temp_directory;
2844 use std::{
2845 collections::BTreeSet,
2846 ffi::OsString,
2847 fs,
2848 path::{Path, PathBuf},
2849 time::{Duration, SystemTime, UNIX_EPOCH},
2850 };
2851
2852 #[cfg(unix)]
2853 use std::os::unix::fs::PermissionsExt as _;
2854
2855 #[test]
2856 fn metadata_receives_only_resolution_arguments() {
2857 let arguments = [
2858 OsString::from("--profile"),
2859 OsString::from("fast"),
2860 OsString::from("--locked"),
2861 OsString::from("--features=alpha,beta"),
2862 ];
2863 assert_eq!(
2864 metadata_arguments(&arguments),
2865 [
2866 OsString::from("--locked"),
2867 OsString::from("--features=alpha,beta"),
2868 ]
2869 );
2870 }
2871
2872 #[test]
2873 fn os_native_builders_preserve_dynamic_values() {
2874 let spec = WasmBuildSpec::new(Path::new("."), Path::new("target"), &["fixture"], "debug")
2875 .with_cargo_profile_args_os([OsString::from("--locked")])
2876 .with_extra_env_os([(OsString::from("MODE"), OsString::from("exact"))])
2877 .with_inherited_env_os([OsString::from("RUSTFLAGS")])
2878 .with_additional_input_paths([PathBuf::from("schema")]);
2879
2880 assert_eq!(spec.cargo_profile_args, [OsString::from("--locked")]);
2881 assert_eq!(
2882 spec.extra_env.get(&OsString::from("MODE")),
2883 Some(&OsString::from("exact"))
2884 );
2885 assert!(spec.inherited_env.contains(&OsString::from("RUSTFLAGS")));
2886 assert_eq!(spec.additional_inputs, [PathBuf::from("schema")]);
2887 }
2888
2889 #[test]
2890 fn public_cargo_input_snapshot_detects_local_source_changes() {
2891 let root = unique_temp_directory("resolved-cargo-inputs");
2892 let package = root.join("fixture");
2893 fs::create_dir_all(package.join("src")).expect("create Cargo input fixture");
2894 fs::write(
2895 root.join("Cargo.toml"),
2896 "[workspace]\nmembers = [\"fixture\"]\nresolver = \"2\"\n",
2897 )
2898 .expect("write fixture workspace manifest");
2899 fs::write(
2900 package.join("Cargo.toml"),
2901 "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\nedition = \"2024\"\n",
2902 )
2903 .expect("write fixture package manifest");
2904 fs::write(package.join("src/lib.rs"), "pub fn value() -> u8 { 1 }\n")
2905 .expect("write fixture source");
2906 let spec = WasmBuildSpec::new(&root, &root.join("target"), &["fixture"], "debug");
2907
2908 let snapshot = resolve_cargo_build_inputs(&spec).expect("resolve Cargo input snapshot");
2909 assert!(
2910 snapshot
2911 .is_current(&spec)
2912 .expect("revalidate unchanged inputs")
2913 );
2914 assert!(
2915 snapshot
2916 .inputs()
2917 .iter()
2918 .any(|input| input.path() == package)
2919 );
2920 assert!(
2921 snapshot
2922 .is_content_current()
2923 .expect("rehash unchanged resolved inputs")
2924 );
2925
2926 fs::write(package.join("src/lib.rs"), "pub fn value() -> u8 { 2 }\n")
2927 .expect("change fixture source");
2928 assert!(!snapshot.is_current(&spec).expect("detect changed input"));
2929 assert!(
2930 !snapshot
2931 .is_content_current()
2932 .expect("rehash changed resolved inputs")
2933 );
2934
2935 let unsafe_path = package.join("src/generated-target");
2936 let unsafe_target = spec.clone().with_shared_incremental_target(&unsafe_path);
2937 assert!(matches!(
2938 resolve_cargo_build_inputs(&unsafe_target),
2939 Err(WasmBuildError::InvalidSpec { .. })
2940 ));
2941 fs::create_dir_all(&unsafe_path).expect("create unsafe maintenance target");
2942 let sentinel = unsafe_path.join("source-sentinel");
2943 fs::write(&sentinel, b"preserve").expect("write unsafe maintenance sentinel");
2944 let maintenance = maintain_shared_incremental_target(
2945 &unsafe_target,
2946 SharedIncrementalTargetPrunePolicy::new().with_max_size_bytes(0),
2947 );
2948 assert!(
2949 matches!(maintenance, Err(WasmBuildError::InvalidSpec { .. })),
2950 "unsafe maintenance returned {maintenance:?}",
2951 );
2952 assert!(sentinel.is_file());
2953
2954 let broad_target = spec.with_shared_incremental_target(&root);
2955 assert!(matches!(
2956 resolve_cargo_build_inputs(&broad_target),
2957 Err(WasmBuildError::InvalidSpec { .. })
2958 ));
2959 fs::remove_dir_all(root).expect("remove Cargo input fixture");
2960 }
2961
2962 #[test]
2963 fn build_spec_requires_at_least_one_package() {
2964 let spec = WasmBuildSpec::new(Path::new("."), Path::new("target"), &[], "debug");
2965 assert!(matches!(
2966 validate_spec(&spec),
2967 Err(WasmBuildError::InvalidSpec { .. })
2968 ));
2969 }
2970
2971 #[test]
2972 fn shared_target_inspection_is_explicit_and_does_not_create_a_missing_target() {
2973 let root = unique_temp_directory("shared-target-inspection");
2974 let target = root.join("missing-shared-target");
2975 let isolated = WasmBuildSpec::new(&root, &root.join("exact"), &["fixture"], "debug");
2976 assert!(matches!(
2977 inspect_shared_incremental_target(&isolated),
2978 Err(WasmBuildError::InvalidSpec { .. })
2979 ));
2980
2981 let shared = isolated.with_shared_incremental_target(&target);
2982 assert!(
2983 inspect_shared_incremental_target(&shared)
2984 .expect("inspect missing shared target")
2985 .is_none()
2986 );
2987 assert!(!target.exists());
2988 fs::remove_dir_all(root).expect("remove shared-target inspection fixture");
2989 }
2990
2991 #[test]
2992 fn shared_target_maintenance_clears_cargo_state_but_preserves_coordination() {
2993 let root = unique_temp_directory("shared-target-maintenance");
2994 let package = root.join("fixture");
2995 fs::create_dir_all(package.join("src")).expect("create maintenance fixture package");
2996 fs::write(
2997 root.join("Cargo.toml"),
2998 "[workspace]\nmembers = [\"fixture\"]\nresolver = \"2\"\n",
2999 )
3000 .expect("write maintenance fixture workspace");
3001 fs::write(
3002 package.join("Cargo.toml"),
3003 "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\nedition = \"2024\"\n",
3004 )
3005 .expect("write maintenance fixture manifest");
3006 fs::write(package.join("src/lib.rs"), "pub fn fixture() {}\n")
3007 .expect("write maintenance fixture source");
3008 let target = root.join("shared-target");
3009 fs::create_dir_all(target.join("debug/deps")).expect("create shared Cargo state");
3010 fs::write(target.join("debug/deps/object.o"), vec![7_u8; 128])
3011 .expect("write shared Cargo state");
3012 fs::write(target.join(".rustc_info.json"), b"rustc state")
3013 .expect("write shared Cargo metadata");
3014 let spec = WasmBuildSpec::new(&root, &root.join("exact"), &["fixture"], "debug")
3015 .with_shared_incremental_target(&target);
3016
3017 let report = maintain_shared_incremental_target(
3018 &spec,
3019 SharedIncrementalTargetPrunePolicy::new().with_max_size_bytes(0),
3020 )
3021 .expect("maintain shared target")
3022 .expect("existing shared target report");
3023
3024 assert!(report.was_cleared());
3025 assert!(report.logical_size_bytes_before() > report.logical_size_bytes_after());
3026 assert!(!target.join("debug").exists());
3027 assert!(!target.join(".rustc_info.json").exists());
3028 assert!(target.join("CACHEDIR.TAG").is_file());
3029 assert!(target.join(".ic-testkit/wasm-incremental.lock").is_file());
3030
3031 let retained =
3032 maintain_shared_incremental_target(&spec, SharedIncrementalTargetPrunePolicy::new())
3033 .expect("reinspect shared target")
3034 .expect("existing shared target report");
3035 assert!(!retained.was_cleared());
3036 assert_eq!(
3037 retained.logical_size_bytes_before(),
3038 retained.logical_size_bytes_after()
3039 );
3040 fs::remove_dir_all(root).expect("remove shared-target maintenance fixture");
3041 }
3042
3043 #[test]
3044 fn zero_progress_heartbeat_is_rejected_before_build_validation() {
3045 let spec = WasmBuildSpec::new(Path::new("."), Path::new("target"), &[], "debug");
3046 let result = build_wasm_canisters_cached_with_progress(
3047 &spec,
3048 WasmBuildProgressConfig::new().with_heartbeat_interval(Duration::ZERO),
3049 |_| {},
3050 );
3051 assert!(
3052 matches!(result, Err(WasmBuildError::InvalidSpec { message }) if message.contains("heartbeat"))
3053 );
3054 }
3055
3056 #[test]
3057 #[cfg(unix)]
3058 fn observed_cargo_build_forwards_raw_output_and_quiet_heartbeats() {
3059 let root = unique_temp_directory("observed-cargo-progress");
3060 let cargo = root.join("observed-cargo.sh");
3061 fs::write(
3062 &cargo,
3063 "#!/bin/sh\nprintf 'observed-stdout'\nprintf 'observed-stderr' >&2\nsleep 0.05\n",
3064 )
3065 .expect("write observed Cargo fixture");
3066 let mut permissions = fs::metadata(&cargo)
3067 .expect("read script metadata")
3068 .permissions();
3069 permissions.set_mode(0o755);
3070 fs::set_permissions(&cargo, permissions).expect("make Cargo fixture executable");
3071 let spec = WasmBuildSpec::new(&root, &root.join("exact"), &["fixture"], "debug")
3072 .with_cargo_program(&cargo);
3073 let mut events = Vec::new();
3074 {
3075 let mut observer = |event| events.push(event);
3076 let mut progress = ProgressReporter {
3077 config: WasmBuildProgressConfig::new()
3078 .with_heartbeat_interval(Duration::from_millis(10)),
3079 observer: Some(&mut observer),
3080 };
3081
3082 run_cargo_build(&spec, &root.join("cargo-target"), &mut progress)
3083 .expect("run observed Cargo fixture");
3084 }
3085
3086 let mut stdout = Vec::new();
3087 let mut stderr = Vec::new();
3088 for event in &events {
3089 if let WasmBuildProgressEvent::CargoOutput { stream, bytes } = event {
3090 match stream {
3091 WasmBuildOutputStream::Stdout => stdout.extend_from_slice(bytes),
3092 WasmBuildOutputStream::Stderr => stderr.extend_from_slice(bytes),
3093 }
3094 }
3095 }
3096 assert_eq!(stdout, b"observed-stdout");
3097 assert_eq!(stderr, b"observed-stderr");
3098 assert!(
3099 events
3100 .iter()
3101 .any(|event| matches!(event, WasmBuildProgressEvent::CargoHeartbeat { .. }))
3102 );
3103 assert!(events.iter().any(|event| matches!(
3104 event,
3105 WasmBuildProgressEvent::CargoFinished { success: true, .. }
3106 )));
3107 fs::remove_dir_all(root).expect("remove observed Cargo fixture");
3108 }
3109
3110 #[test]
3111 #[cfg(unix)]
3112 fn observed_cargo_failure_retains_captured_diagnostics_and_exit_event() {
3113 let root = unique_temp_directory("observed-cargo-failure");
3114 let cargo = root.join("failing-cargo.sh");
3115 fs::write(
3116 &cargo,
3117 "#!/bin/sh\nprintf 'failure-stdout'\nprintf 'failure-stderr' >&2\nexit 23\n",
3118 )
3119 .expect("write failing Cargo fixture");
3120 let mut permissions = fs::metadata(&cargo)
3121 .expect("read script metadata")
3122 .permissions();
3123 permissions.set_mode(0o755);
3124 fs::set_permissions(&cargo, permissions).expect("make Cargo fixture executable");
3125 let spec = WasmBuildSpec::new(&root, &root.join("exact"), &["fixture"], "debug")
3126 .with_cargo_program(&cargo);
3127 let mut events = Vec::new();
3128 let error = {
3129 let mut observer = |event| events.push(event);
3130 let mut progress = ProgressReporter {
3131 config: WasmBuildProgressConfig::new().without_heartbeats(),
3132 observer: Some(&mut observer),
3133 };
3134
3135 run_cargo_build(&spec, &root.join("cargo-target"), &mut progress)
3136 .expect_err("Cargo fixture must fail")
3137 };
3138
3139 assert!(matches!(
3140 error,
3141 WasmBuildError::CommandFailed {
3142 status,
3143 stdout,
3144 stderr,
3145 ..
3146 } if status.code() == Some(23)
3147 && stdout == "failure-stdout"
3148 && stderr == "failure-stderr"
3149 ));
3150 assert!(events.iter().any(|event| matches!(
3151 event,
3152 WasmBuildProgressEvent::CargoFinished {
3153 success: false,
3154 code: Some(23),
3155 ..
3156 }
3157 )));
3158 fs::remove_dir_all(root).expect("remove failing observed Cargo fixture");
3159 }
3160
3161 #[test]
3162 fn cache_directory_tag_is_created_at_target_root() {
3163 let target_dir = unique_temp_directory("cache-directory-tag");
3164 fs::write(target_dir.join("CACHEDIR.TAG"), "not a cache tag")
3165 .expect("write invalid cache tag");
3166
3167 ensure_cache_directory_tag(&target_dir).expect("write valid cache tag");
3168
3169 let contents =
3170 fs::read_to_string(target_dir.join("CACHEDIR.TAG")).expect("read cache directory tag");
3171 assert!(contents.starts_with(CACHE_DIRECTORY_TAG_SIGNATURE));
3172 fs::remove_dir_all(target_dir).expect("remove tag test directory");
3173 }
3174
3175 #[test]
3176 fn failed_build_removes_its_incomplete_fingerprint_directory() {
3177 let target_dir = unique_temp_directory("failed-build-cleanup");
3178 let fingerprint_dir = target_dir.join("a".repeat(64));
3179 fs::create_dir_all(&fingerprint_dir).expect("create incomplete target directory");
3180 fs::write(fingerprint_dir.join("partial-output"), b"partial")
3181 .expect("write incomplete output");
3182 let failure: Result<WasmBuildOutcome, WasmBuildError> = Err(WasmBuildError::InvalidSpec {
3183 message: "synthetic build failure".to_owned(),
3184 });
3185
3186 let result = finish_fingerprint_build(
3187 failure,
3188 IncompleteBuildDirectory::new(fingerprint_dir.clone()),
3189 );
3190
3191 assert!(matches!(result, Err(WasmBuildError::InvalidSpec { .. })));
3192 assert!(!fingerprint_dir.exists());
3193 fs::remove_dir_all(target_dir).expect("remove cleanup test directory");
3194 }
3195
3196 #[test]
3197 fn age_pruning_removes_only_stale_fingerprint_directories() {
3198 let target_dir = unique_temp_directory("age-pruning");
3199 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
3200 let old = create_cache_entry(&cache_root, 'a', 10, UNIX_EPOCH + Duration::from_secs(1));
3201 let current = create_cache_entry(&cache_root, 'b', 10, SystemTime::now());
3202 let unrelated = cache_root.join("not-a-fingerprint");
3203 fs::create_dir_all(&unrelated).expect("create unrelated directory");
3204
3205 let report = prune_wasm_build_cache(
3206 &target_dir,
3207 WasmBuildCachePrunePolicy::new().with_max_age(Duration::from_secs(60)),
3208 )
3209 .expect("prune old cache entry");
3210
3211 assert_eq!(report.entries_scanned(), 2);
3212 assert_eq!(report.entries_removed(), 1);
3213 assert_eq!(report.entries_retained(), 1);
3214 assert!(!old.exists());
3215 assert!(current.exists());
3216 assert!(unrelated.exists());
3217 assert!(target_dir.join("CACHEDIR.TAG").is_file());
3218 fs::remove_dir_all(target_dir).expect("remove age-pruning test directory");
3219 }
3220
3221 #[test]
3222 fn size_pruning_removes_least_recently_used_entries_first() {
3223 let target_dir = unique_temp_directory("size-pruning");
3224 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
3225 let oldest = create_cache_entry(&cache_root, 'a', 10, UNIX_EPOCH + Duration::from_secs(1));
3226 let middle = create_cache_entry(&cache_root, 'b', 10, UNIX_EPOCH + Duration::from_secs(2));
3227 let newest = create_cache_entry(&cache_root, 'c', 10, UNIX_EPOCH + Duration::from_secs(3));
3228 let newest_bytes = directory_logical_size(&newest).expect("measure newest entry");
3229
3230 let report = prune_wasm_build_cache(
3231 &target_dir,
3232 WasmBuildCachePrunePolicy::new().with_max_size_bytes(newest_bytes),
3233 )
3234 .expect("prune cache to size");
3235
3236 assert_eq!(report.entries_scanned(), 3);
3237 assert_eq!(report.entries_removed(), 2);
3238 assert_eq!(report.entries_retained(), 1);
3239 assert!(report.bytes_retained() <= newest_bytes);
3240 assert!(!oldest.exists());
3241 assert!(!middle.exists());
3242 assert!(newest.exists());
3243 fs::remove_dir_all(target_dir).expect("remove size-pruning test directory");
3244 }
3245
3246 #[test]
3247 fn in_build_pruning_protects_the_active_fingerprint() {
3248 let target_dir = unique_temp_directory("protected-pruning");
3249 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
3250 let stale = create_cache_entry(&cache_root, 'a', 10, UNIX_EPOCH + Duration::from_secs(1));
3251 let active = create_cache_entry(&cache_root, 'b', 10, UNIX_EPOCH + Duration::from_secs(2));
3252
3253 let report = prune_wasm_build_cache_locked(
3254 &target_dir,
3255 WasmBuildCachePrunePolicy::new()
3256 .with_max_age(Duration::ZERO)
3257 .with_max_size_bytes(0),
3258 Some(&active),
3259 )
3260 .expect("prune while protecting active cache entry");
3261
3262 assert_eq!(report.entries_scanned(), 2);
3263 assert_eq!(report.entries_removed(), 1);
3264 assert!(!stale.exists());
3265 assert!(active.exists());
3266 assert!(report.bytes_retained() > 0);
3267 fs::remove_dir_all(target_dir).expect("remove protected-pruning test directory");
3268 }
3269
3270 #[test]
3271 fn cargo_configuration_discovery_matches_cargo_search_and_include_rules() {
3272 let root = unique_temp_directory("cargo-configuration-discovery");
3273 let workspace = root.join("workspace");
3274 let workspace_cargo = workspace.join(".cargo");
3275 let ancestor_cargo = root.join(".cargo");
3276 let cargo_home = root.join("cargo-home");
3277 fs::create_dir_all(&workspace_cargo).expect("create workspace Cargo directory");
3278 fs::create_dir_all(&ancestor_cargo).expect("create ancestor Cargo directory");
3279 fs::create_dir_all(&cargo_home).expect("create Cargo home");
3280
3281 fs::write(
3282 workspace_cargo.join("config"),
3283 "include = [\"included.toml\", { path = \"missing.toml\", optional = true }]\n",
3284 )
3285 .expect("write effective workspace Cargo config");
3286 fs::write(
3287 workspace_cargo.join("config.toml"),
3288 "[build]\ntarget-dir = \"ignored-by-cargo\"\n",
3289 )
3290 .expect("write shadowed workspace Cargo config");
3291 fs::write(
3292 workspace_cargo.join("included.toml"),
3293 "include = \"nested.toml\"\n",
3294 )
3295 .expect("write included Cargo config");
3296 fs::write(
3297 workspace_cargo.join("nested.toml"),
3298 "[build]\nincremental = false\n",
3299 )
3300 .expect("write nested Cargo config");
3301 fs::write(
3302 ancestor_cargo.join("config.toml"),
3303 "[net]\noffline = true\n",
3304 )
3305 .expect("write ancestor Cargo config");
3306 fs::write(cargo_home.join("config"), "[term]\nquiet = true\n")
3307 .expect("write Cargo-home config");
3308
3309 let cargo_home_text = cargo_home.to_str().expect("temporary path is UTF-8");
3310 let spec = WasmBuildSpec::new(&workspace, &root.join("target"), &["fixture"], "debug")
3311 .with_extra_env(&[("CARGO_HOME", cargo_home_text)]);
3312 let mut inputs = Vec::new();
3313 append_cargo_configuration_inputs(&mut inputs, &spec, &workspace)
3314 .expect("discover effective Cargo configuration");
3315 let paths = inputs
3316 .into_iter()
3317 .map(|(_, path)| path)
3318 .collect::<BTreeSet<_>>();
3319
3320 assert!(paths.contains(&workspace_cargo.join("config").canonicalize().unwrap()));
3321 assert!(
3322 paths.contains(
3323 &workspace_cargo
3324 .join("included.toml")
3325 .canonicalize()
3326 .unwrap()
3327 )
3328 );
3329 assert!(paths.contains(&workspace_cargo.join("nested.toml").canonicalize().unwrap()));
3330 assert!(paths.contains(&ancestor_cargo.join("config.toml").canonicalize().unwrap()));
3331 assert!(paths.contains(&cargo_home.join("config").canonicalize().unwrap()));
3332 assert!(!paths.contains(&workspace_cargo.join("config.toml").canonicalize().unwrap()));
3333 assert_eq!(paths.len(), 5);
3334 fs::remove_dir_all(root).expect("remove Cargo-configuration test directory");
3335 }
3336
3337 #[test]
3338 fn required_cargo_configuration_include_is_an_exact_input() {
3339 let root = unique_temp_directory("required-cargo-configuration-include");
3340 let workspace = root.join("workspace");
3341 let cargo_dir = workspace.join(".cargo");
3342 fs::create_dir_all(&cargo_dir).expect("create workspace Cargo directory");
3343 fs::write(
3344 cargo_dir.join("config.toml"),
3345 "include = \"missing.toml\"\n",
3346 )
3347 .expect("write Cargo config");
3348 let isolated_home = root.join("isolated-cargo-home");
3349 let isolated_home_text = isolated_home.to_str().expect("temporary path is UTF-8");
3350 let spec = WasmBuildSpec::new(&workspace, &root.join("target"), &["fixture"], "debug")
3351 .with_extra_env(&[("CARGO_HOME", isolated_home_text)]);
3352
3353 let error = append_cargo_configuration_inputs(&mut Vec::new(), &spec, &workspace)
3354 .expect_err("required missing include must fail input discovery");
3355
3356 assert!(matches!(error, WasmBuildError::Io { .. }));
3357 fs::remove_dir_all(root).expect("remove required-include test directory");
3358 }
3359
3360 fn create_cache_entry(
3361 cache_root: &Path,
3362 fingerprint_digit: char,
3363 payload_bytes: usize,
3364 last_used: SystemTime,
3365 ) -> PathBuf {
3366 let path = cache_root.join(fingerprint_digit.to_string().repeat(64));
3367 fs::create_dir_all(&path).expect("create cache entry");
3368 fs::write(path.join("payload"), vec![0; payload_bytes]).expect("write cache payload");
3369 write_last_used(&path, last_used).expect("write cache use time");
3370 path
3371 }
3372}