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