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