1use super::phase::WasmBuildPhase;
2use crate::cargo::{
3 execution::{
4 CargoInvocation, CargoRecipe, CargoTool, TOOL_IDENTITY_BYTES, communicate_cargo,
5 wasm_artifact_paths,
6 },
7 messages::artifact_matches,
8 observation::schema,
9 selection::CargoSelection,
10};
11use ic_host_fs::owned_directory::{OwnedDirectory, remove_path_if_present};
12use ic_host_fs::read::directory_logical_size;
13use ic_host_process::{
14 child::{CleanupPolicy, OwnedChild},
15 tool::{
16 CompletedExecution, ExecutionFailure, ExecutionOperation, OutputLimit, OutputLimits,
17 OutputStream, ToolError, capture_command, capture_group_command_with_cancellation,
18 },
19};
20use std::{
21 cell::RefCell,
22 collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
23 ffi::{OsStr, OsString},
24 fs::{self, File},
25 io,
26 path::{Component, Path, PathBuf},
27 process::{Command, ExitStatus, Stdio},
28 sync::{
29 Arc, RwLock,
30 atomic::{AtomicUsize, Ordering},
31 mpsc::{self, RecvTimeoutError},
32 },
33 thread,
34 time::{Duration, Instant, SystemTime},
35};
36use toml::Value as TomlValue;
37
38mod acquisition;
39use acquisition::prepare_exact_cache_entry;
40
41use super::{
42 cache_fs::{
43 ArtifactCacheMaintenance, ArtifactCachePruneReport, CacheFsError, CacheRetentionLimits,
44 RetainedCacheEntry, cache_entry_last_used, cache_maintenance_due,
45 canonicalize_allow_missing, ensure_cache_directory_tag as ensure_cache_tag,
46 is_sha256_directory, lock_cache_file, lock_cache_file_with_wait_observer,
47 perform_scheduled_cache_maintenance, prune_direct_child_directories,
48 record_cache_entry_use as record_entry_use, record_cache_maintenance,
49 remove_unretained_entry,
50 },
51 digest::{
52 FileDigest, InputDigest, InputHasher, LabeledPathDigestCache, copy_file_atomic,
53 destination_matches_bytes, digest_bytes, digest_file, digest_labeled_paths_composable,
54 materialize_verified_output, os_bytes, read_stamp_with_limit,
55 },
56};
57
58const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
59const BUILD_FINGERPRINT_DOMAIN: &str = "ic-build-explicit-environment-fingerprint";
61const ARTIFACT_STAMP_DIGEST_LABEL: &str = "artifact-sha256:";
62const CARGO_DIAGNOSTIC_BYTES: usize = 1024 * 1024;
64const CARGO_METADATA_BYTES: usize = 16 * 1024 * 1024;
65
66const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
67
68#[derive(Clone, Eq, PartialEq)]
76pub struct WasmBuildSpec {
77 workspace_root: PathBuf,
78 target_dir: PathBuf,
79 packages: Vec<String>,
80 example: Option<String>,
81 cargo_selection: CargoSelection,
82 environment: BTreeMap<OsString, OsString>,
83 additional_inputs: Vec<PathBuf>,
84 target: String,
85 cargo_program: OsString,
86 rustc_program: OsString,
87 cargo_message_bytes: usize,
88 cache_mode: WasmBuildCacheMode,
89 pruning: Option<(CacheRetentionLimits, Duration)>,
90 shared_incremental_maintenance_config: Option<SharedIncrementalTargetMaintenanceConfig>,
91}
92
93impl std::fmt::Debug for WasmBuildSpec {
94 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 formatter
96 .debug_struct("WasmBuildSpec")
97 .field("workspace_root", &self.workspace_root)
98 .field("target_dir", &self.target_dir)
99 .field("packages", &self.packages)
100 .field("example", &self.example)
101 .field("cargo_selection", &self.cargo_selection)
102 .field(
103 "environment_names",
104 &self.environment.keys().collect::<Vec<_>>(),
105 )
106 .field("additional_inputs", &self.additional_inputs)
107 .field("target", &self.target)
108 .field("cargo_program", &self.cargo_program)
109 .field("rustc_program", &self.rustc_program)
110 .field("cargo_message_bytes", &self.cargo_message_bytes)
111 .field("cache_mode", &self.cache_mode)
112 .field("pruning", &self.pruning)
113 .field(
114 "shared_incremental_maintenance_config",
115 &self.shared_incremental_maintenance_config,
116 )
117 .finish()
118 }
119}
120
121#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
123pub enum SharedIncrementalTargetMaintenanceFailureMode {
124 #[default]
126 Strict,
127 BestEffort,
129}
130
131#[derive(Clone, Copy, Debug, Eq, PartialEq)]
133pub struct SharedIncrementalTargetMaintenanceConfig {
134 policy: CacheRetentionLimits,
135 minimum_interval: Duration,
136 failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
137}
138
139#[non_exhaustive]
141#[derive(Clone, Debug, Eq, PartialEq)]
142pub enum WasmBuildCacheMode {
143 Isolated,
145 SharedIncremental {
147 target_dir: PathBuf,
149 },
150}
151
152#[derive(Clone, Debug, Eq, PartialEq)]
154pub enum WasmBuildOutcome {
155 Built(WasmBuildRecord),
157 Reused(WasmBuildRecord),
159}
160
161#[derive(Clone, Debug, Eq, PartialEq)]
163pub struct WasmBuildRecord {
164 facts: CargoBuildFacts,
165 fingerprint: InputDigest,
166 input_digest: InputDigest,
167 retention: RetainedCacheEntry,
168 artifacts: Arc<[PathBuf]>,
169 timings: WasmBuildTimings,
170 maintenance: Option<ArtifactCacheMaintenance>,
171 shared_incremental_maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
172}
173
174#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
176pub struct WasmBuildTimings {
177 lock_wait: Duration,
178 shared_incremental_lock_wait: Option<Duration>,
179 input_resolution: WasmInputResolutionTimings,
180 cargo_build: Option<Duration>,
181 cache_maintenance: Option<Duration>,
182 total: Duration,
183}
184
185#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
187pub struct WasmInputResolutionTimings {
188 tool_identity: Duration,
189 cargo_metadata: Duration,
190 input_discovery: Duration,
191 content_hashing: Duration,
192 total: Duration,
193}
194
195#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
197pub struct WasmBuildFailureTimings {
198 exact_cache_coordination: Duration,
199 shared_target_coordination: Option<Duration>,
200 input_resolution: WasmInputResolutionTimings,
201 shared_target_maintenance: Option<Duration>,
202 cargo_build: Option<Duration>,
203 artifact_publication: Option<Duration>,
204 exact_cache_maintenance: Option<Duration>,
205 cleanup: Option<Duration>,
206 total: Duration,
207}
208
209#[derive(Clone, Copy, Debug, Eq, PartialEq)]
211pub struct WasmBuildFailureDetails {
212 phase: WasmBuildPhase,
213 timings: WasmBuildFailureTimings,
214}
215
216#[derive(Clone, Debug, Eq, PartialEq)]
218pub struct CargoBuildInput {
219 label: PathBuf,
220 path: PathBuf,
221}
222
223#[derive(Clone, Eq, PartialEq)]
231pub struct CargoBuildFacts {
232 pub selection: CargoSelection,
234 pub target: String,
236 pub environment: BTreeMap<OsString, OsString>,
239 pub cargo_identity: Vec<u8>,
241 pub rustc_identity: Vec<u8>,
243}
244
245impl std::fmt::Debug for CargoBuildFacts {
246 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 f.debug_struct("CargoBuildFacts")
248 .field("selection", &self.selection)
249 .field("target", &self.target)
250 .field("environment_entries", &self.environment.len())
251 .field("cargo_identity_bytes", &self.cargo_identity.len())
252 .field("rustc_identity_bytes", &self.rustc_identity.len())
253 .finish()
254 }
255}
256
257#[derive(Clone, Debug, Eq, PartialEq)]
264pub struct ResolvedCargoBuildInputs {
265 facts: CargoBuildFacts,
266 fingerprint: InputDigest,
267 input_digest: InputDigest,
268 validation_digest: InputDigest,
269 inputs: Arc<[CargoBuildInput]>,
270 exclusions: Arc<[PathBuf]>,
271 wasm_outputs: Result<ResolvedWasmOutputs, String>,
272 timings: WasmInputResolutionTimings,
273}
274
275pub(super) struct WasmBuildBatchInputResolver<'a, 'session> {
276 specs: Vec<&'a WasmBuildSpec>,
277 groups: Vec<BatchResolutionGroup>,
278 group_by_index: Vec<usize>,
279 resolved: Vec<Option<Result<ResolvedCargoBuildInputs, (WasmBuildPhase, WasmBuildError)>>>,
280 reuse: Option<&'session WasmBuildInputSnapshotState>,
281 metrics: WasmBuildBatchInputMetrics,
282}
283
284pub(super) struct WasmBuildInputSnapshotState {
285 snapshots: Vec<(WasmBuildSpec, ResolvedCargoBuildInputs)>,
286 preparation_metrics: WasmBuildBatchInputMetrics,
287 preparation_timings: WasmInputResolutionTimings,
288 reader_reuses: AtomicUsize,
289 invalidation: Arc<RwLock<bool>>,
290}
291
292struct BatchResolutionGroup {
293 indexes: Vec<usize>,
294}
295
296#[derive(Clone, Debug, Eq, PartialEq)]
297struct WasmArtifactTarget {
298 package_id: String,
299 name: String,
300 kind: schema::TargetKind,
301}
302
303#[derive(Clone, Debug, Eq, PartialEq)]
304struct ResolvedWasmOutputs {
305 targets: Arc<[WasmArtifactTarget]>,
306 artifact_paths: Arc<[PathBuf]>,
307}
308
309struct ResolvedLocalInputs {
310 validation_inputs: Vec<(PathBuf, PathBuf)>,
311 workspace_projection: Option<InputDigest>,
312 wasm_targets: Result<Vec<WasmArtifactTarget>, String>,
313}
314
315#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
316pub(super) struct WasmBuildBatchInputMetrics {
317 pub(super) runs: usize,
318 pub(super) reuses: usize,
319 pub(super) prepared_reuses: usize,
320}
321
322#[derive(Eq, Hash, PartialEq)]
323struct BatchResolutionKey<'a> {
324 workspace_root: &'a Path,
325 cargo_program: &'a OsStr,
326 rustc_program: &'a OsStr,
327 metadata_arguments: Vec<OsString>,
328 environment: BTreeMap<OsString, OsString>,
329}
330
331#[derive(Clone, Debug, Eq, PartialEq)]
333pub struct SharedIncrementalTargetInspection {
334 target_dir: PathBuf,
335 logical_size_bytes: u64,
336 last_used: SystemTime,
337 lock_wait: Duration,
338}
339
340#[derive(Clone, Debug, Eq, PartialEq)]
342pub struct SharedIncrementalTargetMaintenance {
343 target_dir: PathBuf,
344 logical_size_bytes_before: u64,
345 logical_size_bytes_after: u64,
346 last_used_before: SystemTime,
347 cleared: bool,
348 lock_wait: Duration,
349 maintenance: Duration,
350}
351
352#[non_exhaustive]
354#[derive(Clone, Debug, Eq, PartialEq)]
355pub enum SharedIncrementalTargetMaintenanceOutcome {
356 Missing {
358 target_dir: PathBuf,
360 },
361 Skipped {
363 target_dir: PathBuf,
365 lock_wait: Duration,
367 schedule_check: Duration,
369 },
370 Performed {
372 maintenance: SharedIncrementalTargetMaintenance,
374 schedule_check: Duration,
376 },
377 Failed {
379 target_dir: PathBuf,
381 lock_wait: Duration,
383 message: String,
385 },
386}
387
388#[derive(Clone, Copy, Debug, Eq, PartialEq)]
390pub struct WasmBuildProgressConfig {
391 heartbeat_interval: Option<Duration>,
392 emit_cargo_output: bool,
393}
394
395#[derive(Clone, Copy, Debug, Eq, PartialEq)]
397pub enum WasmBuildProgressOutcome {
398 Built,
400 Reused,
402}
403
404#[non_exhaustive]
406#[derive(Clone, Debug, Eq, PartialEq)]
407pub enum WasmBuildProgressEvent {
408 Started,
410 InputsResolved {
412 fingerprint: InputDigest,
414 input_digest: InputDigest,
416 elapsed: Duration,
418 },
419 CacheMiss {
421 fingerprint: InputDigest,
423 },
424 CacheHit {
426 fingerprint: InputDigest,
428 },
429 SharedTargetLockStarted {
431 target_dir: PathBuf,
433 },
434 SharedTargetLockAcquired {
436 target_dir: PathBuf,
438 wait: Duration,
440 },
441 SharedTargetMaintenanceStarted {
443 target_dir: PathBuf,
445 },
446 SharedTargetMaintenanceFinished {
448 outcome: SharedIncrementalTargetMaintenanceOutcome,
450 },
451 CargoStarted {
453 target_dir: PathBuf,
455 },
456 CargoOutput {
458 stream: OutputStream,
460 bytes: Vec<u8>,
462 },
463 Heartbeat {
465 phase: WasmBuildPhase,
467 elapsed: Duration,
469 },
470 CargoFinished {
472 success: bool,
474 code: Option<i32>,
476 elapsed: Duration,
478 },
479 Finished {
481 outcome: WasmBuildProgressOutcome,
483 fingerprint: InputDigest,
485 elapsed: Duration,
487 },
488}
489
490impl Default for WasmBuildProgressConfig {
491 fn default() -> Self {
492 Self {
493 heartbeat_interval: Some(Duration::from_secs(10)),
494 emit_cargo_output: true,
495 }
496 }
497}
498
499impl WasmBuildProgressConfig {
500 #[must_use]
502 pub fn new() -> Self {
503 Self::default()
504 }
505
506 #[must_use]
510 pub const fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
511 self.heartbeat_interval = Some(interval);
512 self
513 }
514
515 #[must_use]
517 pub const fn without_heartbeats(mut self) -> Self {
518 self.heartbeat_interval = None;
519 self
520 }
521
522 #[must_use]
528 pub const fn with_cargo_output(mut self, emit: bool) -> Self {
529 self.emit_cargo_output = emit;
530 self
531 }
532
533 #[must_use]
535 pub const fn heartbeat_interval(self) -> Option<Duration> {
536 self.heartbeat_interval
537 }
538
539 #[must_use]
541 pub const fn emits_cargo_output(self) -> bool {
542 self.emit_cargo_output
543 }
544}
545
546struct ProgressReporter<'a> {
547 config: WasmBuildProgressConfig,
548 observer: Option<&'a mut dyn FnMut(WasmBuildProgressEvent)>,
549 last_event: Instant,
550 failure_phase: Option<WasmBuildPhase>,
551 failure_timings: WasmBuildFailureTimings,
552}
553
554impl<'a> ProgressReporter<'a> {
555 fn silent() -> Self {
556 Self {
557 config: WasmBuildProgressConfig {
558 heartbeat_interval: None,
559 emit_cargo_output: false,
560 },
561 observer: None,
562 last_event: Instant::now(),
563 failure_phase: None,
564 failure_timings: WasmBuildFailureTimings::default(),
565 }
566 }
567
568 fn new(
569 observation: Option<(
570 WasmBuildProgressConfig,
571 &'a mut dyn FnMut(WasmBuildProgressEvent),
572 )>,
573 ) -> Result<Self, WasmBuildError> {
574 let Some((config, observer)) = observation else {
575 return Ok(Self::silent());
576 };
577 if config.heartbeat_interval == Some(Duration::ZERO) {
578 return Err(WasmBuildError::InvalidSpec {
579 message: "Wasm build progress heartbeat interval must be greater than zero"
580 .to_owned(),
581 });
582 }
583 Ok(ProgressReporter {
584 config,
585 observer: Some(observer),
586 last_event: Instant::now(),
587 failure_phase: None,
588 failure_timings: WasmBuildFailureTimings::default(),
589 })
590 }
591
592 fn emit(&mut self, event: WasmBuildProgressEvent) {
593 if let Some(observer) = &mut self.observer {
594 observer(event);
595 self.last_event = Instant::now();
596 }
597 }
598
599 const fn is_observed(&self) -> bool {
600 self.observer.is_some()
601 }
602
603 fn heartbeat_due_in(&self) -> Option<Duration> {
604 self.config
605 .heartbeat_interval
606 .map(|interval| interval.saturating_sub(self.last_event.elapsed()))
607 }
608
609 fn emit_heartbeat(&mut self, phase: WasmBuildPhase, elapsed: Duration) {
610 self.emit(WasmBuildProgressEvent::Heartbeat { phase, elapsed });
611 }
612
613 fn emit_heartbeat_if_due(&mut self, phase: WasmBuildPhase, elapsed: Duration) {
614 if self.heartbeat_due_in() == Some(Duration::ZERO) {
615 self.emit_heartbeat(phase, elapsed);
616 }
617 }
618
619 fn run_phase<T, F>(&mut self, phase: WasmBuildPhase, operation: F) -> T
620 where
621 T: Send,
622 F: FnOnce() -> T + Send,
623 {
624 let started = Instant::now();
625 self.begin_phase(phase);
626 let result = if !self.is_observed() || self.config.heartbeat_interval.is_none() {
627 operation()
628 } else {
629 thread::scope(|scope| {
630 let (finished, completion) = mpsc::sync_channel(0);
631 let worker = scope.spawn(move || {
632 let result = operation();
633 let _ = finished.send(());
634 result
635 });
636 loop {
637 let wait = self
638 .heartbeat_due_in()
639 .expect("observed phase must have a heartbeat interval");
640 match completion.recv_timeout(wait) {
641 Ok(()) | Err(RecvTimeoutError::Disconnected) => {
642 return worker
643 .join()
644 .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
645 }
646 Err(RecvTimeoutError::Timeout) => {
647 self.emit_heartbeat(phase, started.elapsed());
648 }
649 }
650 }
651 })
652 };
653 self.record_phase(phase, started.elapsed());
654 result
655 }
656
657 const fn begin_phase(&mut self, phase: WasmBuildPhase) {
658 self.failure_phase = Some(phase);
659 }
660
661 fn record_phase(&mut self, phase: WasmBuildPhase, elapsed: Duration) {
662 self.failure_phase = Some(phase);
663 let timings = &mut self.failure_timings;
664 let timing = match phase {
667 WasmBuildPhase::Specification => return,
668 WasmBuildPhase::ExactCacheCoordination => &mut timings.exact_cache_coordination,
669 WasmBuildPhase::CargoIdentity | WasmBuildPhase::RustcIdentity => {
670 &mut timings.input_resolution.tool_identity
671 }
672 WasmBuildPhase::CargoMetadata => &mut timings.input_resolution.cargo_metadata,
673 WasmBuildPhase::InputDiscovery => &mut timings.input_resolution.input_discovery,
674 WasmBuildPhase::ContentHashing => &mut timings.input_resolution.content_hashing,
675 WasmBuildPhase::SharedTargetCoordination => timings
676 .shared_target_coordination
677 .get_or_insert(Duration::ZERO),
678 WasmBuildPhase::SharedTargetMaintenance => timings
679 .shared_target_maintenance
680 .get_or_insert(Duration::ZERO),
681 WasmBuildPhase::CargoBuild => timings.cargo_build.get_or_insert(Duration::ZERO),
682 WasmBuildPhase::ArtifactPublication => {
683 timings.artifact_publication.get_or_insert(Duration::ZERO)
684 }
685 WasmBuildPhase::ExactCacheMaintenance => timings
686 .exact_cache_maintenance
687 .get_or_insert(Duration::ZERO),
688 WasmBuildPhase::Cleanup => timings.cleanup.get_or_insert(Duration::ZERO),
689 };
690 *timing = timing.saturating_add(elapsed);
691 if matches!(
692 phase,
693 WasmBuildPhase::CargoIdentity
694 | WasmBuildPhase::RustcIdentity
695 | WasmBuildPhase::CargoMetadata
696 | WasmBuildPhase::InputDiscovery
697 | WasmBuildPhase::ContentHashing
698 ) {
699 timings.input_resolution.total = timings.input_resolution.total.saturating_add(elapsed);
700 }
701 }
702
703 fn failure_details(&self, error: &WasmBuildError, total: Duration) -> WasmBuildFailureDetails {
704 let phase = self
705 .failure_phase
706 .unwrap_or_else(|| classify_unobserved_failure(error));
707 let mut timings = self.failure_timings;
708 timings.total = total;
709 WasmBuildFailureDetails { phase, timings }
710 }
711}
712
713fn classify_unobserved_failure(error: &WasmBuildError) -> WasmBuildPhase {
714 match error {
715 WasmBuildError::InvalidSpec { .. } => WasmBuildPhase::Specification,
716 WasmBuildError::CommandSpawn { phase, .. }
717 | WasmBuildError::CommandFailed { phase, .. } => *phase,
718 WasmBuildError::InvalidMetadata { .. } => WasmBuildPhase::CargoMetadata,
719 WasmBuildError::InvalidCargoConfiguration { .. } => WasmBuildPhase::InputDiscovery,
720 WasmBuildError::MissingArtifacts { .. }
721 | WasmBuildError::PreparedInputSnapshotInvalidated => WasmBuildPhase::ArtifactPublication,
722 WasmBuildError::InputsChangedDuringAcquisition { .. } => WasmBuildPhase::ContentHashing,
723 WasmBuildError::FailedBuildCleanup { .. } => WasmBuildPhase::Cleanup,
724 WasmBuildError::Io { source, .. }
725 if source
726 .get_ref()
727 .is_some_and(<dyn std::error::Error + Send + Sync>::is::<ToolError>) =>
728 {
729 WasmBuildPhase::CargoBuild
730 }
731 WasmBuildError::Io { .. } => WasmBuildPhase::ExactCacheCoordination,
732 }
733}
734
735#[non_exhaustive]
737#[derive(Debug)]
738pub enum WasmBuildError {
739 InvalidSpec {
741 message: String,
743 },
744 Io {
746 operation: &'static str,
748 path: PathBuf,
750 source: io::Error,
752 },
753 CommandSpawn {
755 phase: WasmBuildPhase,
757 program: OsString,
759 source: io::Error,
761 },
762 CommandFailed {
766 phase: WasmBuildPhase,
768 status: ExitStatus,
770 stdout: String,
772 stderr: String,
774 },
775 InvalidMetadata {
777 message: String,
779 },
780 InvalidCargoConfiguration {
782 path: PathBuf,
784 message: String,
786 },
787 MissingArtifacts {
789 paths: Vec<PathBuf>,
791 },
792 InputsChangedDuringAcquisition {
794 before: InputDigest,
796 after: InputDigest,
798 },
799 PreparedInputSnapshotInvalidated,
801 FailedBuildCleanup {
803 build_error: Box<Self>,
805 path: PathBuf,
807 source: io::Error,
809 },
810}
811
812impl WasmBuildSpec {
813 #[must_use]
819 pub const fn with_cargo_message_bytes(mut self, maximum: usize) -> Self {
820 self.cargo_message_bytes = maximum;
821 self
822 }
823
824 #[must_use]
848 pub fn new<I, K, V>(
849 workspace_root: &Path,
850 target_dir: &Path,
851 packages: &[&str],
852 environment: I,
853 ) -> Self
854 where
855 I: IntoIterator<Item = (K, V)>,
856 K: Into<OsString>,
857 V: Into<OsString>,
858 {
859 let environment: BTreeMap<OsString, OsString> = environment
860 .into_iter()
861 .map(|(key, value)| (key.into(), value.into()))
862 .collect();
863 let mut packages = packages
864 .iter()
865 .map(|package| (*package).to_owned())
866 .collect::<Vec<_>>();
867 packages.sort();
868 packages.dedup();
869 Self {
870 workspace_root: workspace_root.to_owned(),
871 target_dir: target_dir.to_owned(),
872 packages,
873 example: None,
874 cargo_selection: CargoSelection::default(),
875 cargo_program: environment
876 .get(OsStr::new("CARGO"))
877 .cloned()
878 .unwrap_or_else(|| "cargo".into()),
879 rustc_program: environment
880 .get(OsStr::new("RUSTC"))
881 .cloned()
882 .unwrap_or_else(|| "rustc".into()),
883 environment,
884 additional_inputs: Vec::new(),
885 target: DEFAULT_TARGET.to_owned(),
886 cargo_message_bytes: CARGO_METADATA_BYTES,
887 cache_mode: WasmBuildCacheMode::SharedIncremental {
888 target_dir: PathBuf::from("target/ic-testkit-incremental"),
889 },
890 pruning: None,
891 shared_incremental_maintenance_config: None,
892 }
893 }
894
895 #[must_use]
901 pub fn with_example(mut self, name: impl Into<String>) -> Self {
902 self.example = Some(name.into());
903 self
904 }
905
906 #[must_use]
908 pub fn example(&self) -> Option<&str> {
909 self.example.as_deref()
910 }
911
912 pub fn cargo_build_command(&self) -> Result<Command, WasmBuildError> {
924 validate_spec(self)?;
925 cargo_build_command(self, &self.target_dir)
926 }
927
928 #[must_use]
933 pub fn with_cargo_selection(mut self, selection: CargoSelection) -> Self {
934 self.cargo_selection = selection;
935 self
936 }
937
938 #[must_use]
944 pub fn with_environment<I, K, V>(mut self, environment: I) -> Self
945 where
946 I: IntoIterator<Item = (K, V)>,
947 K: Into<OsString>,
948 V: Into<OsString>,
949 {
950 self.environment = environment
951 .into_iter()
952 .map(|(key, value)| (key.into(), value.into()))
953 .collect();
954 self
955 }
956
957 #[must_use]
959 pub const fn environment(&self) -> &BTreeMap<OsString, OsString> {
960 &self.environment
961 }
962
963 #[must_use]
968 pub fn with_additional_inputs<I, P>(mut self, paths: I) -> Self
969 where
970 I: IntoIterator<Item = P>,
971 P: Into<PathBuf>,
972 {
973 self.additional_inputs
974 .extend(paths.into_iter().map(Into::into));
975 self
976 }
977
978 #[must_use]
980 pub fn with_target(mut self, target: &str) -> Self {
981 target.clone_into(&mut self.target);
982 self
983 }
984
985 #[must_use]
987 pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
988 self.cargo_program = program.into();
989 self
990 }
991
992 #[must_use]
1000 pub fn with_rustc_identity_program(mut self, program: impl Into<OsString>) -> Self {
1001 self.rustc_program = program.into();
1002 self
1003 }
1004
1005 #[must_use]
1011 pub fn with_shared_incremental_target(mut self, target_dir: impl Into<PathBuf>) -> Self {
1012 self.cache_mode = WasmBuildCacheMode::SharedIncremental {
1013 target_dir: target_dir.into(),
1014 };
1015 self
1016 }
1017
1018 #[must_use]
1023 pub fn with_isolated_builds(mut self) -> Self {
1024 self.cache_mode = WasmBuildCacheMode::Isolated;
1025 self
1026 }
1027
1028 #[must_use]
1039 pub const fn with_shared_incremental_target_maintenance_at_most_every(
1040 mut self,
1041 policy: CacheRetentionLimits,
1042 minimum_interval: Duration,
1043 ) -> Self {
1044 self.shared_incremental_maintenance_config = Some(
1045 SharedIncrementalTargetMaintenanceConfig::new(policy, minimum_interval),
1046 );
1047 self
1048 }
1049
1050 #[must_use]
1056 pub const fn with_shared_incremental_target_maintenance(
1057 mut self,
1058 config: SharedIncrementalTargetMaintenanceConfig,
1059 ) -> Self {
1060 self.shared_incremental_maintenance_config = Some(config);
1061 self
1062 }
1063
1064 #[must_use]
1070 pub const fn with_prune_policy(mut self, policy: CacheRetentionLimits) -> Self {
1071 self.pruning = Some((policy, Duration::ZERO));
1072 self
1073 }
1074
1075 #[must_use]
1082 pub const fn with_prune_policy_at_most_every(
1083 mut self,
1084 policy: CacheRetentionLimits,
1085 minimum_interval: Duration,
1086 ) -> Self {
1087 self.pruning = Some((policy, minimum_interval));
1088 self
1089 }
1090
1091 #[must_use]
1093 pub fn workspace_root(&self) -> &Path {
1094 &self.workspace_root
1095 }
1096
1097 #[must_use]
1099 pub fn target_dir(&self) -> &Path {
1100 &self.target_dir
1101 }
1102
1103 #[must_use]
1105 pub fn packages(&self) -> &[String] {
1106 &self.packages
1107 }
1108
1109 #[must_use]
1111 pub const fn cache_mode(&self) -> &WasmBuildCacheMode {
1112 &self.cache_mode
1113 }
1114
1115 #[must_use]
1117 pub const fn prune_policy(&self) -> Option<CacheRetentionLimits> {
1118 match self.pruning {
1119 Some((policy, _)) => Some(policy),
1120 None => None,
1121 }
1122 }
1123
1124 #[must_use]
1129 pub const fn prune_interval(&self) -> Option<Duration> {
1130 match self.pruning {
1131 Some((_, interval)) if !interval.is_zero() => Some(interval),
1132 _ => None,
1133 }
1134 }
1135
1136 #[must_use]
1138 pub const fn shared_incremental_target_maintenance(
1139 &self,
1140 ) -> Option<SharedIncrementalTargetMaintenanceConfig> {
1141 self.shared_incremental_maintenance_config
1142 }
1143}
1144
1145impl WasmBuildOutcome {
1146 #[must_use]
1148 pub const fn record(&self) -> &WasmBuildRecord {
1149 match self {
1150 Self::Built(record) | Self::Reused(record) => record,
1151 }
1152 }
1153
1154 #[must_use]
1156 pub const fn is_reused(&self) -> bool {
1157 matches!(self, Self::Reused(_))
1158 }
1159}
1160
1161impl WasmBuildRecord {
1162 #[must_use]
1168 pub const fn facts(&self) -> &CargoBuildFacts {
1169 &self.facts
1170 }
1171
1172 #[must_use]
1174 pub const fn fingerprint(&self) -> InputDigest {
1175 self.fingerprint
1176 }
1177
1178 #[must_use]
1180 pub const fn input_digest(&self) -> InputDigest {
1181 self.input_digest
1182 }
1183
1184 #[must_use]
1191 pub fn exact_cache_path(&self) -> &Path {
1192 self.retention.path()
1193 }
1194
1195 #[must_use]
1200 pub fn artifacts(&self) -> &[PathBuf] {
1201 &self.artifacts
1202 }
1203
1204 #[must_use]
1206 pub const fn timings(&self) -> WasmBuildTimings {
1207 self.timings
1208 }
1209
1210 #[must_use]
1212 pub const fn maintenance(&self) -> Option<&ArtifactCacheMaintenance> {
1213 self.maintenance.as_ref()
1214 }
1215
1216 #[must_use]
1218 pub const fn shared_incremental_maintenance(
1219 &self,
1220 ) -> Option<&SharedIncrementalTargetMaintenanceOutcome> {
1221 self.shared_incremental_maintenance.as_ref()
1222 }
1223}
1224
1225impl WasmBuildTimings {
1226 #[must_use]
1228 pub const fn lock_wait(self) -> Duration {
1229 self.lock_wait
1230 }
1231
1232 #[must_use]
1234 pub const fn shared_incremental_lock_wait(self) -> Option<Duration> {
1235 self.shared_incremental_lock_wait
1236 }
1237
1238 #[must_use]
1240 pub const fn input_resolution(self) -> WasmInputResolutionTimings {
1241 self.input_resolution
1242 }
1243
1244 #[must_use]
1246 pub const fn cargo_build(self) -> Option<Duration> {
1247 self.cargo_build
1248 }
1249
1250 #[must_use]
1252 pub const fn cache_maintenance(self) -> Option<Duration> {
1253 self.cache_maintenance
1254 }
1255
1256 #[must_use]
1258 pub const fn total(self) -> Duration {
1259 self.total
1260 }
1261
1262 pub(super) fn saturating_add(self, other: Self) -> Self {
1263 let mut input_resolution = self.input_resolution;
1264 input_resolution.include(other.input_resolution);
1265 Self {
1266 lock_wait: self.lock_wait.saturating_add(other.lock_wait),
1267 shared_incremental_lock_wait: [
1268 self.shared_incremental_lock_wait,
1269 other.shared_incremental_lock_wait,
1270 ]
1271 .into_iter()
1272 .flatten()
1273 .reduce(Duration::saturating_add),
1274 input_resolution,
1275 cargo_build: [self.cargo_build, other.cargo_build]
1276 .into_iter()
1277 .flatten()
1278 .reduce(Duration::saturating_add),
1279 cache_maintenance: [self.cache_maintenance, other.cache_maintenance]
1280 .into_iter()
1281 .flatten()
1282 .reduce(Duration::saturating_add),
1283 total: self.total.saturating_add(other.total),
1284 }
1285 }
1286}
1287
1288impl WasmInputResolutionTimings {
1289 #[must_use]
1291 pub const fn tool_identity(self) -> Duration {
1292 self.tool_identity
1293 }
1294
1295 #[must_use]
1297 pub const fn cargo_metadata(self) -> Duration {
1298 self.cargo_metadata
1299 }
1300
1301 #[must_use]
1303 pub const fn input_discovery(self) -> Duration {
1304 self.input_discovery
1305 }
1306
1307 #[must_use]
1309 pub const fn content_hashing(self) -> Duration {
1310 self.content_hashing
1311 }
1312
1313 #[must_use]
1315 pub const fn total(self) -> Duration {
1316 self.total
1317 }
1318
1319 const fn include(&mut self, other: Self) {
1320 self.tool_identity = self.tool_identity.saturating_add(other.tool_identity);
1321 self.cargo_metadata = self.cargo_metadata.saturating_add(other.cargo_metadata);
1322 self.input_discovery = self.input_discovery.saturating_add(other.input_discovery);
1323 self.content_hashing = self.content_hashing.saturating_add(other.content_hashing);
1324 self.total = self.total.saturating_add(other.total);
1325 }
1326}
1327
1328impl WasmBuildFailureDetails {
1329 pub(super) fn specification(total: Duration) -> Self {
1330 Self {
1331 phase: WasmBuildPhase::Specification,
1332 timings: WasmBuildFailureTimings {
1333 total,
1334 ..WasmBuildFailureTimings::default()
1335 },
1336 }
1337 }
1338
1339 #[must_use]
1341 pub const fn phase(self) -> WasmBuildPhase {
1342 self.phase
1343 }
1344
1345 #[must_use]
1347 pub const fn timings(self) -> WasmBuildFailureTimings {
1348 self.timings
1349 }
1350}
1351
1352impl WasmBuildFailureTimings {
1353 #[must_use]
1355 pub const fn exact_cache_coordination(self) -> Duration {
1356 self.exact_cache_coordination
1357 }
1358
1359 #[must_use]
1361 pub const fn shared_target_coordination(self) -> Option<Duration> {
1362 self.shared_target_coordination
1363 }
1364
1365 #[must_use]
1367 pub const fn input_resolution(self) -> WasmInputResolutionTimings {
1368 self.input_resolution
1369 }
1370
1371 #[must_use]
1373 pub const fn shared_target_maintenance(self) -> Option<Duration> {
1374 self.shared_target_maintenance
1375 }
1376
1377 #[must_use]
1379 pub const fn cargo_build(self) -> Option<Duration> {
1380 self.cargo_build
1381 }
1382
1383 #[must_use]
1385 pub const fn artifact_publication(self) -> Option<Duration> {
1386 self.artifact_publication
1387 }
1388
1389 #[must_use]
1391 pub const fn exact_cache_maintenance(self) -> Option<Duration> {
1392 self.exact_cache_maintenance
1393 }
1394
1395 #[must_use]
1397 pub const fn cleanup(self) -> Option<Duration> {
1398 self.cleanup
1399 }
1400
1401 #[must_use]
1403 pub const fn total(self) -> Duration {
1404 self.total
1405 }
1406}
1407
1408impl CargoBuildInput {
1409 #[must_use]
1411 pub fn label(&self) -> &Path {
1412 &self.label
1413 }
1414
1415 #[must_use]
1420 pub fn path(&self) -> &Path {
1421 &self.path
1422 }
1423}
1424
1425pub struct CargoInputProbePolicy<'a> {
1432 pub deadline: Option<Instant>,
1435 pub cleanup: CleanupPolicy,
1437 pub cancelled: &'a mut (dyn FnMut() -> bool + Send),
1440}
1441
1442impl ResolvedCargoBuildInputs {
1443 #[must_use]
1445 pub const fn facts(&self) -> &CargoBuildFacts {
1446 &self.facts
1447 }
1448
1449 fn assemble(
1450 spec: &WasmBuildSpec,
1451 inputs: ResolvedLocalInputs,
1452 exclusions: Vec<PathBuf>,
1453 input_digest: InputDigest,
1454 validation_digest: InputDigest,
1455 facts: CargoBuildFacts,
1456 timings: impl FnOnce() -> WasmInputResolutionTimings,
1457 ) -> Self {
1458 Self {
1459 fingerprint: finish_build_fingerprint(spec, &facts, input_digest),
1460 facts,
1461 input_digest,
1462 validation_digest,
1463 inputs: cargo_build_inputs(inputs.validation_inputs),
1464 exclusions: exclusions.into(),
1465 wasm_outputs: inputs.wasm_targets.map(|targets| ResolvedWasmOutputs {
1466 artifact_paths: expected_artifacts(spec, &spec.target_dir, &targets).into(),
1467 targets: targets.into(),
1468 }),
1469 timings: timings(),
1471 }
1472 }
1473
1474 pub fn artifact_paths(&self) -> Result<&[PathBuf], WasmBuildError> {
1484 self.wasm_outputs()
1485 .map(|outputs| outputs.artifact_paths.as_ref())
1486 }
1487
1488 fn wasm_outputs(&self) -> Result<&ResolvedWasmOutputs, WasmBuildError> {
1489 self.wasm_outputs
1490 .as_ref()
1491 .map_err(|message| WasmBuildError::InvalidSpec {
1492 message: message.clone(),
1493 })
1494 }
1495
1496 #[must_use]
1498 pub const fn fingerprint(&self) -> InputDigest {
1499 self.fingerprint
1500 }
1501
1502 #[must_use]
1507 pub const fn input_digest(&self) -> InputDigest {
1508 self.input_digest
1509 }
1510
1511 #[must_use]
1516 pub const fn validation_digest(&self) -> InputDigest {
1517 self.validation_digest
1518 }
1519
1520 #[must_use]
1522 pub fn inputs(&self) -> &[CargoBuildInput] {
1523 &self.inputs
1524 }
1525
1526 #[must_use]
1531 pub fn exclusions(&self) -> &[PathBuf] {
1532 &self.exclusions
1533 }
1534
1535 #[must_use]
1537 pub const fn timings(&self) -> WasmInputResolutionTimings {
1538 self.timings
1539 }
1540
1541 pub fn is_current(&self, spec: &WasmBuildSpec) -> Result<bool, WasmBuildError> {
1546 resolve_cargo_build_inputs(spec).map(|current| current.fingerprint == self.fingerprint)
1547 }
1548
1549 pub fn is_current_with_probe_policy(
1557 &self,
1558 spec: &WasmBuildSpec,
1559 policy: &mut CargoInputProbePolicy<'_>,
1560 ) -> Result<bool, WasmBuildError> {
1561 resolve_cargo_build_inputs_with_probe_policy(spec, policy)
1562 .map(|current| current.fingerprint == self.fingerprint)
1563 }
1564
1565 pub fn is_content_current(&self) -> Result<bool, WasmBuildError> {
1575 self.current_validation_digest()
1576 .map(|current| current == self.validation_digest)
1577 }
1578
1579 pub(super) fn current_validation_digest(&self) -> Result<InputDigest, WasmBuildError> {
1580 digest_labeled_paths_composable(
1581 "wasm-source-inputs-v1",
1582 self.inputs
1583 .iter()
1584 .map(|input| (input.label.as_path(), input.path.as_path())),
1585 &self.exclusions,
1586 &mut LabeledPathDigestCache::default(),
1587 )
1588 .map_err(|source| WasmBuildError::Io {
1589 operation: "rehash resolved Cargo build inputs",
1590 path: self
1591 .inputs
1592 .first()
1593 .map_or_else(PathBuf::new, |input| input.path.clone()),
1594 source,
1595 })
1596 }
1597}
1598
1599impl WasmBuildInputSnapshotState {
1600 pub(super) fn prepare(specs: &[WasmBuildSpec]) -> Result<Self, WasmBuildError> {
1601 for spec in specs {
1602 validate_spec(spec)?;
1603 }
1604 let mut resolver = WasmBuildBatchInputResolver::new(specs, None)
1605 .map_err(|_| WasmBuildError::PreparedInputSnapshotInvalidated)?;
1606 let mut snapshots = Vec::with_capacity(specs.len());
1607 let mut preparation_timings = WasmInputResolutionTimings::default();
1608 let mut progress = ProgressReporter::silent();
1609 for (index, spec) in specs.iter().enumerate() {
1610 let mut prepared_input = resolver.resolve(index, &mut progress)?;
1611 preparation_timings.include(prepared_input.timings);
1612 prepared_input.timings = WasmInputResolutionTimings::default();
1613 snapshots.push((spec.clone(), prepared_input));
1614 }
1615 Ok(Self {
1616 snapshots,
1617 preparation_metrics: resolver.metrics(),
1618 preparation_timings,
1619 reader_reuses: AtomicUsize::new(0),
1620 invalidation: Arc::new(RwLock::new(false)),
1621 })
1622 }
1623
1624 fn admit(&self, specs: &[&WasmBuildSpec]) -> Result<Vec<ResolvedCargoBuildInputs>, usize> {
1625 let matched = specs
1626 .iter()
1627 .enumerate()
1628 .map(|(index, spec)| {
1629 self.snapshots
1630 .iter()
1631 .find(|(candidate, _)| candidate == *spec)
1632 .map(|(_, snapshot)| snapshot.clone())
1633 .ok_or(index)
1634 })
1635 .collect::<Result<Vec<_>, _>>()?;
1636 #[allow(deprecated, reason = "try_update is unstable at the Rust 1.91 MSRV")]
1637 let _ = self
1638 .reader_reuses
1639 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
1640 Some(current.saturating_add(matched.len()))
1641 });
1642 Ok(matched)
1643 }
1644
1645 pub(super) const fn specification_count(&self) -> usize {
1646 self.snapshots.len()
1647 }
1648
1649 pub(super) const fn preparation_metrics(&self) -> WasmBuildBatchInputMetrics {
1650 self.preparation_metrics
1651 }
1652
1653 pub(super) const fn preparation_timings(&self) -> WasmInputResolutionTimings {
1654 self.preparation_timings
1655 }
1656
1657 pub(super) fn reader_reuses(&self) -> usize {
1658 self.reader_reuses.load(Ordering::Relaxed)
1659 }
1660
1661 pub(super) fn is_invalidated(&self) -> bool {
1662 *self
1663 .invalidation
1664 .read()
1665 .unwrap_or_else(std::sync::PoisonError::into_inner)
1666 }
1667
1668 fn invalidate(&self) {
1669 *self
1670 .invalidation
1671 .write()
1672 .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
1673 }
1674
1675 fn invalidation(&self) -> Arc<RwLock<bool>> {
1676 Arc::clone(&self.invalidation)
1677 }
1678}
1679
1680impl<'a, 'session> WasmBuildBatchInputResolver<'a, 'session> {
1681 pub(super) fn new(
1682 specs: impl IntoIterator<Item = &'a WasmBuildSpec>,
1683 reuse: Option<&'session WasmBuildInputSnapshotState>,
1684 ) -> Result<Self, usize> {
1685 let specs = specs.into_iter().collect::<Vec<_>>();
1686 let prepared = match reuse.as_ref() {
1687 Some(snapshot) => Some(snapshot.admit(&specs)?),
1688 _ => None,
1689 };
1690 let mut groups_by_key = HashMap::new();
1691 let mut groups = Vec::<BatchResolutionGroup>::new();
1692 let mut group_by_index = Vec::with_capacity(specs.len());
1693 for (index, spec) in specs.iter().copied().enumerate() {
1694 let group = *groups_by_key
1695 .entry(BatchResolutionKey::for_spec(spec))
1696 .or_insert_with(|| {
1697 groups.push(BatchResolutionGroup {
1698 indexes: Vec::new(),
1699 });
1700 groups.len() - 1
1701 });
1702 groups[group].indexes.push(index);
1703 group_by_index.push(group);
1704 }
1705 let mut metrics = WasmBuildBatchInputMetrics::default();
1706 let resolved = prepared.map_or_else(
1707 || specs.iter().map(|_| None).collect(),
1708 |prepared| {
1709 metrics.prepared_reuses = prepared.len();
1710 prepared
1711 .into_iter()
1712 .map(|snapshot| Some(Ok(snapshot)))
1713 .collect()
1714 },
1715 );
1716 Ok(Self {
1717 specs,
1718 groups,
1719 group_by_index,
1720 resolved,
1721 reuse,
1722 metrics,
1723 })
1724 }
1725
1726 pub(super) const fn metrics(&self) -> WasmBuildBatchInputMetrics {
1727 self.metrics
1728 }
1729
1730 pub(super) fn invalidate_source_lease(&self) {
1731 if let Some(snapshot) = self.reuse {
1732 snapshot.invalidate();
1733 }
1734 }
1735
1736 pub(super) fn prepared_invalidation(&self) -> Option<Arc<RwLock<bool>>> {
1737 self.reuse.map(WasmBuildInputSnapshotState::invalidation)
1738 }
1739
1740 fn resolve(
1741 &mut self,
1742 index: usize,
1743 progress: &mut ProgressReporter<'_>,
1744 ) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1745 if self.resolved[index].is_none() {
1746 self.resolve_group(index, progress)?;
1747 }
1748 self.resolved[index]
1749 .take()
1750 .expect("resolved batch input must be populated")
1751 .map_err(|(phase, error)| {
1752 progress.begin_phase(phase);
1753 error
1754 })
1755 }
1756
1757 fn resolve_group(
1758 &mut self,
1759 active_index: usize,
1760 progress: &mut ProgressReporter<'_>,
1761 ) -> Result<(), WasmBuildError> {
1762 let total_started = Instant::now();
1763 let group = self.group_by_index[active_index];
1764 let active = self.specs[active_index];
1765
1766 let (cargo_identity, rustc_identity, tool_identity) =
1767 resolve_tool_identity(active, progress)?;
1768
1769 let metadata_started = Instant::now();
1770 let metadata =
1771 progress.run_phase(WasmBuildPhase::CargoMetadata, || cargo_metadata(active))?;
1772 let cargo_metadata = metadata_started.elapsed();
1773
1774 let (discovered, input_discovery) = self.discover_group_inputs(group, &metadata, progress);
1775
1776 let hashing_started = Instant::now();
1777 let mut batch_digest_cache = LabeledPathDigestCache::default();
1778 let digest_cache = &mut batch_digest_cache;
1779 let workspace_root = &active.workspace_root;
1780 let resolved_inputs = progress.run_phase(WasmBuildPhase::ContentHashing, || {
1781 discovered
1782 .into_iter()
1783 .map(|(index, inputs, exclusions)| {
1784 let result = digest_resolved_local_inputs(
1785 &inputs,
1786 &exclusions,
1787 digest_cache,
1788 workspace_root,
1789 "hash batched Wasm build inputs",
1790 "hash batched semantic Wasm build inputs",
1791 )
1792 .map(|(input_digest, validation_digest)| {
1793 (inputs, exclusions, input_digest, validation_digest)
1794 });
1795 (index, result)
1796 })
1797 .collect::<Vec<_>>()
1798 });
1799 let content_hashing = hashing_started.elapsed();
1800 let timings = WasmInputResolutionTimings {
1801 tool_identity,
1802 cargo_metadata,
1803 input_discovery,
1804 content_hashing,
1805 total: total_started.elapsed(),
1806 };
1807 let resolved_count = resolved_inputs
1808 .iter()
1809 .filter(|(_, result)| result.is_ok())
1810 .count();
1811 self.metrics.runs += usize::from(resolved_count > 0);
1812 self.metrics.reuses += resolved_count.saturating_sub(1);
1813 let timing_index = resolved_inputs
1814 .iter()
1815 .find(|(index, result)| *index == active_index && result.is_ok())
1816 .or_else(|| resolved_inputs.iter().find(|(_, result)| result.is_ok()))
1817 .map(|(index, _)| *index);
1818 for (index, result) in resolved_inputs {
1819 let (inputs, exclusions, input_digest, validation_digest) = match result {
1820 Ok(resolved) => resolved,
1821 Err(error) => {
1822 self.resolved[index] = Some(Err((WasmBuildPhase::ContentHashing, error)));
1823 continue;
1824 }
1825 };
1826 let spec = self.specs[index];
1827 let resolved = ResolvedCargoBuildInputs::assemble(
1828 spec,
1829 inputs,
1830 exclusions,
1831 input_digest,
1832 validation_digest,
1833 spec.cargo_invocation()
1834 .facts(cargo_identity.clone(), rustc_identity.clone()),
1835 || {
1836 if Some(index) == timing_index {
1837 timings
1838 } else {
1839 WasmInputResolutionTimings::default()
1840 }
1841 },
1842 );
1843 self.resolved[index] = Some(Ok(resolved));
1844 }
1845 Ok(())
1846 }
1847
1848 fn discover_group_inputs(
1849 &mut self,
1850 group: usize,
1851 metadata: &schema::Metadata,
1852 progress: &mut ProgressReporter<'_>,
1853 ) -> (Vec<(usize, ResolvedLocalInputs, Vec<PathBuf>)>, Duration) {
1854 let started = Instant::now();
1855 let indexes = &self.groups[group].indexes;
1856 let specs = &self.specs;
1857 let resolved = &mut self.resolved;
1858 let discovered = progress.run_phase(WasmBuildPhase::InputDiscovery, || {
1859 let parsed = ParsedCargoMetadata::parse(metadata);
1860 let mut discovered = Vec::new();
1861 for &index in indexes {
1862 if resolved[index].is_some() || validate_spec(specs[index]).is_err() {
1863 continue;
1864 }
1865 let result = parsed
1866 .as_ref()
1867 .map_err(|message| invalid_metadata(message))
1868 .and_then(|parsed| resolve_local_inputs(specs[index], parsed));
1869 match result {
1870 Ok((inputs, exclusions)) => discovered.push((index, inputs, exclusions)),
1871 Err(error) => {
1872 resolved[index] = Some(Err((WasmBuildPhase::InputDiscovery, error)));
1873 }
1874 }
1875 }
1876 discovered
1877 });
1878 (discovered, started.elapsed())
1879 }
1880}
1881
1882fn resolve_tool_identity(
1883 spec: &WasmBuildSpec,
1884 progress: &mut ProgressReporter<'_>,
1885) -> Result<(Vec<u8>, Vec<u8>, Duration), WasmBuildError> {
1886 resolve_tool_identity_with_policy(spec, progress, &mut None)
1887}
1888
1889fn resolve_tool_identity_with_policy(
1890 spec: &WasmBuildSpec,
1891 progress: &mut ProgressReporter<'_>,
1892 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
1893) -> Result<(Vec<u8>, Vec<u8>, Duration), WasmBuildError> {
1894 let started = Instant::now();
1895 let facts = spec
1896 .cargo_invocation()
1897 .observe(&spec.rustc_program, |tool, command| {
1898 let phase = match tool {
1899 CargoTool::Cargo => WasmBuildPhase::CargoIdentity,
1900 CargoTool::Rustc => WasmBuildPhase::RustcIdentity,
1901 };
1902 progress.run_phase(phase, || {
1903 let output =
1904 capture_probe_output_with_policy(command, phase, TOOL_IDENTITY_BYTES, policy)?;
1905 ensure_command_success(phase, output)
1906 })
1907 })?;
1908 Ok((
1909 facts.cargo_identity,
1910 facts.rustc_identity,
1911 started.elapsed(),
1912 ))
1913}
1914
1915impl<'a> BatchResolutionKey<'a> {
1916 fn for_spec(spec: &'a WasmBuildSpec) -> Self {
1917 Self {
1918 workspace_root: &spec.workspace_root,
1919 cargo_program: &spec.cargo_program,
1920 rustc_program: spec
1921 .environment
1922 .get(OsStr::new("RUSTC"))
1923 .unwrap_or(&spec.rustc_program),
1924 metadata_arguments: spec.cargo_selection.metadata_arguments(),
1925 environment: spec.environment.clone(),
1926 }
1927 }
1928}
1929
1930impl SharedIncrementalTargetInspection {
1931 #[must_use]
1933 pub fn target_dir(&self) -> &Path {
1934 &self.target_dir
1935 }
1936
1937 #[must_use]
1939 pub const fn logical_size_bytes(&self) -> u64 {
1940 self.logical_size_bytes
1941 }
1942
1943 #[must_use]
1945 pub const fn last_used(&self) -> SystemTime {
1946 self.last_used
1947 }
1948
1949 #[must_use]
1951 pub const fn lock_wait(&self) -> Duration {
1952 self.lock_wait
1953 }
1954}
1955
1956impl SharedIncrementalTargetMaintenanceConfig {
1957 #[must_use]
1959 pub const fn new(policy: CacheRetentionLimits, minimum_interval: Duration) -> Self {
1960 Self {
1961 policy,
1962 minimum_interval,
1963 failure_mode: SharedIncrementalTargetMaintenanceFailureMode::Strict,
1964 }
1965 }
1966
1967 #[must_use]
1969 pub const fn with_failure_mode(
1970 mut self,
1971 failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
1972 ) -> Self {
1973 self.failure_mode = failure_mode;
1974 self
1975 }
1976
1977 #[must_use]
1979 pub const fn policy(self) -> CacheRetentionLimits {
1980 self.policy
1981 }
1982
1983 #[must_use]
1985 pub const fn minimum_interval(self) -> Duration {
1986 self.minimum_interval
1987 }
1988
1989 #[must_use]
1991 pub const fn failure_mode(self) -> SharedIncrementalTargetMaintenanceFailureMode {
1992 self.failure_mode
1993 }
1994}
1995
1996impl SharedIncrementalTargetMaintenance {
1997 #[must_use]
1999 pub fn target_dir(&self) -> &Path {
2000 &self.target_dir
2001 }
2002
2003 #[must_use]
2005 pub const fn logical_size_bytes_before(&self) -> u64 {
2006 self.logical_size_bytes_before
2007 }
2008
2009 #[must_use]
2011 pub const fn logical_size_bytes_after(&self) -> u64 {
2012 self.logical_size_bytes_after
2013 }
2014
2015 #[must_use]
2017 pub const fn last_used_before(&self) -> SystemTime {
2018 self.last_used_before
2019 }
2020
2021 #[must_use]
2023 pub const fn was_cleared(&self) -> bool {
2024 self.cleared
2025 }
2026
2027 #[must_use]
2029 pub const fn lock_wait(&self) -> Duration {
2030 self.lock_wait
2031 }
2032
2033 #[must_use]
2035 pub const fn maintenance(&self) -> Duration {
2036 self.maintenance
2037 }
2038}
2039
2040impl std::fmt::Display for SharedIncrementalTargetMaintenance {
2041 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2042 write!(
2043 formatter,
2044 "target={} action={} bytes={}=>{} lock={:?} maintenance={:?}",
2045 self.target_dir.display(),
2046 if self.cleared { "cleared" } else { "retained" },
2047 self.logical_size_bytes_before,
2048 self.logical_size_bytes_after,
2049 self.lock_wait,
2050 self.maintenance,
2051 )
2052 }
2053}
2054
2055impl SharedIncrementalTargetMaintenanceOutcome {
2056 #[must_use]
2058 pub fn target_dir(&self) -> &Path {
2059 match self {
2060 Self::Missing { target_dir }
2061 | Self::Skipped { target_dir, .. }
2062 | Self::Failed { target_dir, .. } => target_dir,
2063 Self::Performed { maintenance, .. } => maintenance.target_dir(),
2064 }
2065 }
2066
2067 #[must_use]
2069 pub const fn maintenance(&self) -> Option<&SharedIncrementalTargetMaintenance> {
2070 match self {
2071 Self::Performed { maintenance, .. } => Some(maintenance),
2072 Self::Missing { .. } | Self::Skipped { .. } | Self::Failed { .. } => None,
2073 }
2074 }
2075
2076 #[must_use]
2078 pub const fn was_performed(&self) -> bool {
2079 matches!(self, Self::Performed { .. })
2080 }
2081
2082 #[must_use]
2084 pub const fn lock_wait(&self) -> Option<Duration> {
2085 match self {
2086 Self::Missing { .. } => None,
2087 Self::Skipped { lock_wait, .. } | Self::Failed { lock_wait, .. } => Some(*lock_wait),
2088 Self::Performed { maintenance, .. } => Some(maintenance.lock_wait()),
2089 }
2090 }
2091
2092 #[must_use]
2094 pub const fn schedule_check(&self) -> Option<Duration> {
2095 match self {
2096 Self::Missing { .. } | Self::Failed { .. } => None,
2097 Self::Skipped { schedule_check, .. } | Self::Performed { schedule_check, .. } => {
2098 Some(*schedule_check)
2099 }
2100 }
2101 }
2102
2103 #[must_use]
2105 pub fn failure_message(&self) -> Option<&str> {
2106 match self {
2107 Self::Failed { message, .. } => Some(message),
2108 Self::Missing { .. } | Self::Skipped { .. } | Self::Performed { .. } => None,
2109 }
2110 }
2111}
2112
2113impl std::fmt::Display for SharedIncrementalTargetMaintenanceOutcome {
2114 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2115 match self {
2116 Self::Missing { target_dir } => {
2117 write!(formatter, "target={} action=missing", target_dir.display())
2118 }
2119 Self::Skipped {
2120 target_dir,
2121 lock_wait,
2122 schedule_check,
2123 } => write!(
2124 formatter,
2125 "target={} action=skipped lock={lock_wait:?} schedule={schedule_check:?}",
2126 target_dir.display(),
2127 ),
2128 Self::Performed {
2129 maintenance,
2130 schedule_check,
2131 } => write!(formatter, "{maintenance} schedule={schedule_check:?}"),
2132 Self::Failed {
2133 target_dir,
2134 lock_wait,
2135 message,
2136 } => write!(
2137 formatter,
2138 "target={} action=failed lock={lock_wait:?} error={message}",
2139 target_dir.display(),
2140 ),
2141 }
2142 }
2143}
2144
2145impl std::fmt::Display for WasmBuildTimings {
2146 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2147 write!(
2148 formatter,
2149 "total={:?} lock={:?} shared_lock={:?} inputs={:?} cargo={:?} maintenance={:?}",
2150 self.total,
2151 self.lock_wait,
2152 self.shared_incremental_lock_wait,
2153 self.input_resolution.total,
2154 self.cargo_build,
2155 self.cache_maintenance,
2156 )
2157 }
2158}
2159
2160impl std::fmt::Display for WasmBuildOutcome {
2161 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2162 let state = if self.is_reused() { "reused" } else { "built" };
2163 write!(
2164 formatter,
2165 "{state} fingerprint={} artifacts={} {}",
2166 self.record().fingerprint,
2167 self.record().artifacts.len(),
2168 self.record().timings,
2169 )?;
2170 if let Some(maintenance) = self.record().shared_incremental_maintenance() {
2171 write!(formatter, " shared_maintenance=({maintenance})")?;
2172 }
2173 Ok(())
2174 }
2175}
2176
2177pub fn resolve_cargo_build_inputs(
2191 spec: &WasmBuildSpec,
2192) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2193 validate_spec(spec)?;
2194 build_fingerprint(spec)
2195}
2196
2197pub fn resolve_cargo_build_inputs_with_probe_policy(
2209 spec: &WasmBuildSpec,
2210 policy: &mut CargoInputProbePolicy<'_>,
2211) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2212 validate_spec(spec)?;
2213 build_fingerprint_with_policy(spec, &mut ProgressReporter::silent(), &mut Some(policy))
2214}
2215
2216pub fn inspect_shared_incremental_target(
2224 spec: &WasmBuildSpec,
2225) -> Result<Option<SharedIncrementalTargetInspection>, WasmBuildError> {
2226 if !shared_incremental_target_exists(spec, "inspect shared incremental Cargo target")? {
2227 return Ok(None);
2228 }
2229
2230 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2231 let logical_size_bytes =
2232 directory_logical_size(&canonical).map_err(|source| WasmBuildError::Io {
2233 operation: "measure shared incremental Cargo target",
2234 path: canonical.clone(),
2235 source,
2236 })?;
2237 let last_used = cache_entry_last_used(&canonical).map_err(|source| WasmBuildError::Io {
2238 operation: "read shared incremental Cargo target use time",
2239 path: canonical.clone(),
2240 source,
2241 })?;
2242 Ok(Some(SharedIncrementalTargetInspection {
2243 target_dir: canonical,
2244 logical_size_bytes,
2245 last_used,
2246 lock_wait,
2247 }))
2248}
2249
2250pub fn maintain_shared_incremental_target(
2268 spec: &WasmBuildSpec,
2269 policy: CacheRetentionLimits,
2270) -> Result<Option<SharedIncrementalTargetMaintenance>, WasmBuildError> {
2271 if !shared_incremental_target_exists(
2272 spec,
2273 "inspect shared incremental Cargo target before maintenance",
2274 )? {
2275 return Ok(None);
2276 }
2277
2278 let _ = resolve_cargo_build_inputs(spec)?;
2282 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2283 maintain_shared_incremental_target_locked(&canonical, policy, lock_wait).map(Some)
2284}
2285
2286pub fn maintain_shared_incremental_target_at_most_every(
2304 spec: &WasmBuildSpec,
2305 policy: CacheRetentionLimits,
2306 minimum_interval: Duration,
2307) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2308 let target_dir =
2309 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
2310 message: "shared incremental target is not configured".to_owned(),
2311 })?;
2312 if !shared_incremental_target_exists(
2313 spec,
2314 "inspect shared incremental Cargo target before scheduled maintenance",
2315 )? {
2316 return Ok(SharedIncrementalTargetMaintenanceOutcome::Missing { target_dir });
2317 }
2318
2319 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2320 let schedule = schedule_shared_incremental_target_maintenance(
2321 &canonical,
2322 policy,
2323 minimum_interval,
2324 lock_wait,
2325 )?;
2326 let schedule = match schedule {
2327 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => return Ok(outcome),
2328 SharedIncrementalTargetMaintenanceSchedule::Due(due) => due,
2329 };
2330
2331 let _ = resolve_cargo_build_inputs(spec)?;
2334 perform_due_shared_incremental_target_maintenance(&canonical, policy, lock_wait, schedule)
2335}
2336
2337enum SharedIncrementalTargetMaintenanceSchedule {
2338 Skipped(SharedIncrementalTargetMaintenanceOutcome),
2339 Due(DueSharedIncrementalTargetMaintenance),
2340}
2341
2342struct DueSharedIncrementalTargetMaintenance {
2343 schedule_root: PathBuf,
2344 maintenance_identity: String,
2345 schedule_check: Duration,
2346}
2347
2348fn schedule_shared_incremental_target_maintenance(
2349 canonical: &Path,
2350 policy: CacheRetentionLimits,
2351 minimum_interval: Duration,
2352 lock_wait: Duration,
2353) -> Result<SharedIncrementalTargetMaintenanceSchedule, WasmBuildError> {
2354 let schedule_root = canonical.join(".ic-testkit");
2355 let maintenance_identity = policy.maintenance_identity();
2356 let schedule_started = Instant::now();
2357 let due = cache_maintenance_due(&schedule_root, minimum_interval, &maintenance_identity)
2358 .map_err(wasm_cache_fs_error)?;
2359 let schedule_check = schedule_started.elapsed();
2360 if !due {
2361 return Ok(SharedIncrementalTargetMaintenanceSchedule::Skipped(
2362 SharedIncrementalTargetMaintenanceOutcome::Skipped {
2363 target_dir: canonical.to_owned(),
2364 lock_wait,
2365 schedule_check,
2366 },
2367 ));
2368 }
2369 Ok(SharedIncrementalTargetMaintenanceSchedule::Due(
2370 DueSharedIncrementalTargetMaintenance {
2371 schedule_root,
2372 maintenance_identity,
2373 schedule_check,
2374 },
2375 ))
2376}
2377
2378fn perform_due_shared_incremental_target_maintenance(
2379 canonical: &Path,
2380 policy: CacheRetentionLimits,
2381 lock_wait: Duration,
2382 due: DueSharedIncrementalTargetMaintenance,
2383) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2384 let DueSharedIncrementalTargetMaintenance {
2385 schedule_root,
2386 maintenance_identity,
2387 schedule_check,
2388 } = due;
2389 let maintenance = maintain_shared_incremental_target_locked(canonical, policy, lock_wait)?;
2390 record_cache_maintenance(&schedule_root, &maintenance_identity).map_err(wasm_cache_fs_error)?;
2391 Ok(SharedIncrementalTargetMaintenanceOutcome::Performed {
2392 maintenance,
2393 schedule_check,
2394 })
2395}
2396
2397fn maintain_shared_incremental_target_locked(
2398 canonical: &Path,
2399 policy: CacheRetentionLimits,
2400 lock_wait: Duration,
2401) -> Result<SharedIncrementalTargetMaintenance, WasmBuildError> {
2402 let started = Instant::now();
2403 let logical_size_bytes_before =
2404 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2405 operation: "measure shared incremental Cargo target before maintenance",
2406 path: canonical.to_owned(),
2407 source,
2408 })?;
2409 let last_used_before =
2410 cache_entry_last_used(canonical).map_err(|source| WasmBuildError::Io {
2411 operation: "read shared incremental Cargo target use time before maintenance",
2412 path: canonical.to_owned(),
2413 source,
2414 })?;
2415 let expired = policy.max_age().is_some_and(|max_age| {
2416 SystemTime::now()
2417 .duration_since(last_used_before)
2418 .is_ok_and(|age| age > max_age)
2419 });
2420 let oversized = policy
2421 .max_size_bytes()
2422 .is_some_and(|max_size_bytes| logical_size_bytes_before > max_size_bytes);
2423 let cleared = expired || oversized;
2424 if cleared {
2425 clear_shared_incremental_target_contents(canonical)?;
2426 record_cache_entry_use(canonical)?;
2427 }
2428 let logical_size_bytes_after = if cleared {
2429 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2430 operation: "measure shared incremental Cargo target after maintenance",
2431 path: canonical.to_owned(),
2432 source,
2433 })?
2434 } else {
2435 logical_size_bytes_before
2436 };
2437 Ok(SharedIncrementalTargetMaintenance {
2438 target_dir: canonical.to_owned(),
2439 logical_size_bytes_before,
2440 logical_size_bytes_after,
2441 last_used_before,
2442 cleared,
2443 lock_wait,
2444 maintenance: started.elapsed(),
2445 })
2446}
2447
2448fn clear_shared_incremental_target_contents(target_dir: &Path) -> Result<(), WasmBuildError> {
2449 let entries = fs::read_dir(target_dir).map_err(|source| WasmBuildError::Io {
2450 operation: "read shared incremental Cargo target for maintenance",
2451 path: target_dir.to_owned(),
2452 source,
2453 })?;
2454 for entry in entries {
2455 let path = entry
2456 .map_err(|source| WasmBuildError::Io {
2457 operation: "read shared incremental Cargo target entry for maintenance",
2458 path: target_dir.to_owned(),
2459 source,
2460 })?
2461 .path();
2462 let preserved = path
2463 .file_name()
2464 .is_some_and(|name| name == ".ic-testkit" || name == "CACHEDIR.TAG");
2465 if !preserved {
2466 remove_path_if_present(&path).map_err(|source| WasmBuildError::Io {
2467 operation: "clear shared incremental Cargo target entry",
2468 path,
2469 source,
2470 })?;
2471 }
2472 }
2473 Ok(())
2474}
2475
2476pub fn build_wasm_canisters_cached(
2507 spec: &WasmBuildSpec,
2508 mut spawn: impl FnMut(&mut Command) -> io::Result<OwnedChild>,
2509) -> Result<WasmBuildOutcome, WasmBuildError> {
2510 build_wasm_canisters_cached_internal(spec, &mut ProgressReporter::silent(), None, &mut spawn)
2511}
2512
2513pub fn build_wasm_canisters_cached_with_progress<F>(
2522 spec: &WasmBuildSpec,
2523 config: WasmBuildProgressConfig,
2524 mut observer: F,
2525 mut spawn: impl FnMut(&mut Command) -> io::Result<OwnedChild>,
2526) -> Result<WasmBuildOutcome, WasmBuildError>
2527where
2528 F: FnMut(WasmBuildProgressEvent),
2529{
2530 build_wasm_canisters_cached_internal(
2531 spec,
2532 &mut ProgressReporter::new(Some((config, &mut observer)))?,
2533 None,
2534 &mut spawn,
2535 )
2536}
2537
2538#[expect(
2539 clippy::result_large_err,
2540 reason = "Batch entries retain complete failure details inline without per-failure allocation."
2541)]
2542pub(super) fn build_wasm_canisters_cached_in_batch(
2543 spec: &WasmBuildSpec,
2544 index: usize,
2545 resolver: &mut WasmBuildBatchInputResolver<'_, '_>,
2546 observation: Option<(
2547 WasmBuildProgressConfig,
2548 &mut dyn FnMut(WasmBuildProgressEvent),
2549 )>,
2550 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
2551) -> Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)> {
2552 let mut progress = ProgressReporter::new(observation).map_err(|error| {
2553 (
2554 error,
2555 WasmBuildFailureDetails::specification(Duration::ZERO),
2556 )
2557 })?;
2558 let started = Instant::now();
2559 let result = build_wasm_canisters_cached_internal(
2560 spec,
2561 &mut progress,
2562 Some((resolver, index)),
2563 &mut spawn,
2564 );
2565 batch_result(result, &progress, resolver, started)
2566}
2567
2568#[expect(
2569 clippy::result_large_err,
2570 reason = "Batch entries retain complete failure details inline without per-failure allocation."
2571)]
2572fn batch_result(
2573 result: Result<WasmBuildOutcome, WasmBuildError>,
2574 progress: &ProgressReporter<'_>,
2575 resolver: &WasmBuildBatchInputResolver<'_, '_>,
2576 started: Instant,
2577) -> Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)> {
2578 if result
2579 .as_ref()
2580 .is_err_and(WasmBuildError::indicates_input_change)
2581 {
2582 resolver.invalidate_source_lease();
2583 }
2584 let total = started.elapsed();
2585 result.map_err(|error| {
2586 let details = progress.failure_details(&error, total);
2587 (error, details)
2588 })
2589}
2590
2591fn batch_prepared_invalidation(
2592 batch_resolution: Option<&(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2593) -> Option<Arc<RwLock<bool>>> {
2594 batch_resolution.and_then(|(resolver, _)| resolver.prepared_invalidation())
2595}
2596
2597fn build_wasm_canisters_cached_internal(
2598 spec: &WasmBuildSpec,
2599 progress: &mut ProgressReporter<'_>,
2600 mut batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2601 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
2602) -> Result<WasmBuildOutcome, WasmBuildError> {
2603 let total_started = Instant::now();
2604 validate_spec(spec)?;
2605 let prepared_invalidation = batch_prepared_invalidation(batch_resolution.as_ref());
2606 progress.emit(WasmBuildProgressEvent::Started);
2607 if spec.shared_incremental_maintenance_config.is_some() {
2608 let outcome = build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2609 spec,
2610 total_started,
2611 progress,
2612 batch_resolution.take(),
2613 &mut spawn,
2614 )?;
2615 emit_finished_progress(&outcome, progress);
2616 return Ok(outcome);
2617 }
2618 let (cache_lock, first_lock_wait) =
2619 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2620 ensure_cache_directory_tag(&spec.target_dir)?;
2621
2622 let resolved = resolve_initial_inputs(spec, batch_resolution.take(), progress)?;
2623 let isolated_acquisition = WasmAcquisitionContext::isolated(prepared_invalidation);
2624 if let Some(outcome) = try_reuse_wasm_artifacts(
2625 spec,
2626 &resolved,
2627 first_lock_wait,
2628 &isolated_acquisition,
2629 total_started,
2630 progress,
2631 )? {
2632 emit_finished_progress(&outcome, progress);
2633 return Ok(outcome);
2634 }
2635 progress.emit(WasmBuildProgressEvent::CacheMiss {
2636 fingerprint: resolved.fingerprint,
2637 });
2638
2639 let outcome = match &spec.cache_mode {
2640 WasmBuildCacheMode::Isolated => {
2641 let cache_entry = cache_entry_directory(spec, resolved.fingerprint);
2642 build_wasm_cache_miss(
2643 spec,
2644 &resolved,
2645 first_lock_wait,
2646 isolated_acquisition,
2647 &cache_entry,
2648 total_started,
2649 progress,
2650 &mut spawn,
2651 )
2652 }
2653 WasmBuildCacheMode::SharedIncremental { .. } => {
2654 drop(cache_lock);
2655 build_wasm_with_shared_incremental(
2656 spec,
2657 resolved,
2658 first_lock_wait,
2659 isolated_acquisition,
2660 total_started,
2661 progress,
2662 &mut spawn,
2663 )
2664 }
2665 }?;
2666 emit_finished_progress(&outcome, progress);
2667 Ok(outcome)
2668}
2669
2670fn build_wasm_with_shared_incremental(
2671 spec: &WasmBuildSpec,
2672 resolved: ResolvedCargoBuildInputs,
2673 first_lock_wait: Duration,
2674 source: WasmAcquisitionContext,
2675 total_started: Instant,
2676 progress: &mut ProgressReporter<'_>,
2677 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
2678) -> Result<WasmBuildOutcome, WasmBuildError> {
2679 let (shared_lock, shared_lock_wait, shared_target) =
2680 lock_shared_incremental_target_with_progress(spec, progress)?;
2681 let (_cache_lock, second_lock_wait) =
2682 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2683 ensure_cache_directory_tag(&spec.target_dir)?;
2684
2685 let current = if source.prepared_invalidation.is_some() {
2686 resolved
2687 } else {
2688 let mut current = resolve_initial_inputs(spec, None, progress)?;
2689 current.timings.include(resolved.timings);
2690 current
2691 };
2692 let lock_wait = first_lock_wait.saturating_add(second_lock_wait);
2693 let shared_incremental =
2694 WasmAcquisitionContext::shared(shared_lock_wait, None, source.prepared_invalidation);
2695 if let Some(outcome) = try_reuse_wasm_artifacts(
2696 spec,
2697 ¤t,
2698 lock_wait,
2699 &shared_incremental,
2700 total_started,
2701 progress,
2702 )? {
2703 return Ok(outcome);
2704 }
2705
2706 let outcome = build_wasm_cache_miss(
2707 spec,
2708 ¤t,
2709 lock_wait,
2710 shared_incremental,
2711 &shared_target,
2712 total_started,
2713 progress,
2714 &mut spawn,
2715 );
2716 drop(shared_lock);
2717 outcome
2718}
2719
2720fn build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2721 spec: &WasmBuildSpec,
2722 total_started: Instant,
2723 progress: &mut ProgressReporter<'_>,
2724 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2725 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
2726) -> Result<WasmBuildOutcome, WasmBuildError> {
2727 let prepared_invalidation = batch_prepared_invalidation(batch_resolution.as_ref());
2728 let (_shared_lock, shared_lock_wait, shared_target) =
2729 lock_shared_incremental_target_with_progress(spec, progress)?;
2730 let (_cache_lock, lock_wait) = lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2731 ensure_cache_directory_tag(&spec.target_dir)?;
2732
2733 let resolved = resolve_initial_inputs(spec, batch_resolution, progress)?;
2736 let shared_maintenance = perform_configured_shared_incremental_target_maintenance(
2737 spec,
2738 &shared_target,
2739 shared_lock_wait,
2740 progress,
2741 )?;
2742 let shared_incremental = WasmAcquisitionContext::shared(
2743 shared_lock_wait,
2744 Some(shared_maintenance),
2745 prepared_invalidation,
2746 );
2747 if let Some(outcome) = try_reuse_wasm_artifacts(
2748 spec,
2749 &resolved,
2750 lock_wait,
2751 &shared_incremental,
2752 total_started,
2753 progress,
2754 )? {
2755 return Ok(outcome);
2756 }
2757 progress.emit(WasmBuildProgressEvent::CacheMiss {
2758 fingerprint: resolved.fingerprint,
2759 });
2760 build_wasm_cache_miss(
2761 spec,
2762 &resolved,
2763 lock_wait,
2764 shared_incremental,
2765 &shared_target,
2766 total_started,
2767 progress,
2768 &mut spawn,
2769 )
2770}
2771
2772fn perform_configured_shared_incremental_target_maintenance(
2773 spec: &WasmBuildSpec,
2774 shared_target: &Path,
2775 lock_wait: Duration,
2776 progress: &mut ProgressReporter<'_>,
2777) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2778 let config = spec
2779 .shared_incremental_maintenance_config
2780 .expect("configured shared-target maintenance must have settings");
2781 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceStarted {
2782 target_dir: shared_target.to_owned(),
2783 });
2784 let result = progress.run_phase(WasmBuildPhase::SharedTargetMaintenance, || {
2785 let schedule = schedule_shared_incremental_target_maintenance(
2786 shared_target,
2787 config.policy,
2788 config.minimum_interval,
2789 lock_wait,
2790 )?;
2791 match schedule {
2792 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => Ok(outcome),
2793 SharedIncrementalTargetMaintenanceSchedule::Due(due) => {
2794 perform_due_shared_incremental_target_maintenance(
2795 shared_target,
2796 config.policy,
2797 lock_wait,
2798 due,
2799 )
2800 }
2801 }
2802 });
2803 let outcome = integrated_shared_maintenance_result(config, shared_target, lock_wait, result)?;
2804 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceFinished {
2805 outcome: outcome.clone(),
2806 });
2807 Ok(outcome)
2808}
2809
2810fn integrated_shared_maintenance_result(
2811 config: SharedIncrementalTargetMaintenanceConfig,
2812 shared_target: &Path,
2813 lock_wait: Duration,
2814 result: Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError>,
2815) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2816 match result {
2817 Ok(outcome) => Ok(outcome),
2818 Err(error)
2819 if config.failure_mode == SharedIncrementalTargetMaintenanceFailureMode::BestEffort =>
2820 {
2821 Ok(SharedIncrementalTargetMaintenanceOutcome::Failed {
2822 target_dir: shared_target.to_owned(),
2823 lock_wait,
2824 message: error.to_string(),
2825 })
2826 }
2827 Err(error) => Err(error),
2828 }
2829}
2830
2831fn resolve_initial_inputs(
2832 spec: &WasmBuildSpec,
2833 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2834 progress: &mut ProgressReporter<'_>,
2835) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2836 let resolved = if let Some((resolver, index)) = batch_resolution {
2837 resolver.resolve(index, progress)?
2838 } else {
2839 build_fingerprint_with_progress(spec, progress)?
2840 };
2841 resolved.wasm_outputs().inspect_err(|_| {
2842 progress.begin_phase(WasmBuildPhase::InputDiscovery);
2843 })?;
2844 progress.emit(WasmBuildProgressEvent::InputsResolved {
2845 fingerprint: resolved.fingerprint,
2846 input_digest: resolved.input_digest,
2847 elapsed: resolved.timings.total,
2848 });
2849 Ok(resolved)
2850}
2851
2852fn emit_finished_progress(outcome: &WasmBuildOutcome, progress: &mut ProgressReporter<'_>) {
2853 let state = if outcome.is_reused() {
2854 progress.emit(WasmBuildProgressEvent::CacheHit {
2855 fingerprint: outcome.record().fingerprint,
2856 });
2857 WasmBuildProgressOutcome::Reused
2858 } else {
2859 WasmBuildProgressOutcome::Built
2860 };
2861 progress.emit(WasmBuildProgressEvent::Finished {
2862 outcome: state,
2863 fingerprint: outcome.record().fingerprint,
2864 elapsed: outcome.record().timings.total,
2865 });
2866}
2867
2868#[derive(Clone, Debug, Default)]
2869struct WasmAcquisitionContext {
2870 lock_wait: Option<Duration>,
2871 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
2872 prepared_invalidation: Option<Arc<RwLock<bool>>>,
2873}
2874
2875impl WasmAcquisitionContext {
2876 fn isolated(prepared_invalidation: Option<Arc<RwLock<bool>>>) -> Self {
2877 Self {
2878 prepared_invalidation,
2879 ..Self::default()
2880 }
2881 }
2882
2883 const fn shared(
2884 lock_wait: Duration,
2885 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
2886 prepared_invalidation: Option<Arc<RwLock<bool>>>,
2887 ) -> Self {
2888 Self {
2889 lock_wait: Some(lock_wait),
2890 maintenance,
2891 prepared_invalidation,
2892 }
2893 }
2894
2895 fn lock_prepared_publication(
2896 &self,
2897 ) -> Result<Option<std::sync::RwLockReadGuard<'_, bool>>, WasmBuildError> {
2898 let guard = self.prepared_invalidation.as_deref().map(|invalidation| {
2899 invalidation
2900 .read()
2901 .unwrap_or_else(std::sync::PoisonError::into_inner)
2902 });
2903 if guard.as_deref().is_some_and(|invalidated| *invalidated) {
2904 return Err(WasmBuildError::PreparedInputSnapshotInvalidated);
2905 }
2906 Ok(guard)
2907 }
2908}
2909
2910fn try_reuse_wasm_artifacts(
2911 spec: &WasmBuildSpec,
2912 resolved: &ResolvedCargoBuildInputs,
2913 lock_wait: Duration,
2914 shared_incremental: &WasmAcquisitionContext,
2915 total_started: Instant,
2916 progress: &mut ProgressReporter<'_>,
2917) -> Result<Option<WasmBuildOutcome>, WasmBuildError> {
2918 let targets = &resolved.wasm_outputs()?.targets;
2919 let _publication_guard = shared_incremental.lock_prepared_publication()?;
2920 let fingerprint = resolved.fingerprint;
2921 let cache_entry = cache_entry_directory(spec, fingerprint);
2922 let paths = artifact_publication_paths(spec, &cache_entry, targets);
2923 let Some(artifact_info) = progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
2924 validated_artifact_set(paths, fingerprint)
2925 }) else {
2926 return Ok(None);
2927 };
2928 progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
2929 materialize_artifacts(&artifact_info, fingerprint, true)?;
2930 record_cache_entry_use(&cache_entry)
2931 })?;
2932 let input_resolution = validate_reused_inputs(spec, resolved, shared_incremental, progress)?;
2933 Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
2934 spec,
2935 BuildRecordInput {
2936 resolved,
2937 lock_wait,
2938 shared_incremental: shared_incremental.clone(),
2939 input_resolution,
2940 cargo_build: None,
2941 active_entry: &cache_entry,
2942 targets,
2943 },
2944 total_started,
2945 progress,
2946 )?)))
2947}
2948
2949fn validate_reused_inputs(
2950 spec: &WasmBuildSpec,
2951 resolved: &ResolvedCargoBuildInputs,
2952 context: &WasmAcquisitionContext,
2953 progress: &mut ProgressReporter<'_>,
2954) -> Result<WasmInputResolutionTimings, WasmBuildError> {
2955 let mut timings = resolved.timings;
2956 if context.prepared_invalidation.is_none() {
2957 let verified = verify_resolved_inputs(spec, resolved, progress)?;
2958 timings.include(verified.timings);
2959 }
2960 Ok(timings)
2961}
2962
2963fn verify_resolved_inputs(
2964 spec: &WasmBuildSpec,
2965 resolved: &ResolvedCargoBuildInputs,
2966 progress: &mut ProgressReporter<'_>,
2967) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2968 let verified = resolve_initial_inputs(spec, None, progress)?;
2969 for (before, after) in [
2970 (resolved.validation_digest, verified.validation_digest),
2971 (resolved.fingerprint, verified.fingerprint),
2972 ] {
2973 if before != after {
2974 return Err(WasmBuildError::InputsChangedDuringAcquisition { before, after });
2975 }
2976 }
2977 Ok(verified)
2978}
2979
2980#[expect(
2981 clippy::too_many_arguments,
2982 reason = "keep the caller's spawner separate from recipe, acquisition and progress state"
2983)]
2984fn build_wasm_cache_miss(
2985 spec: &WasmBuildSpec,
2986 resolved: &ResolvedCargoBuildInputs,
2987 lock_wait: Duration,
2988 shared_incremental: WasmAcquisitionContext,
2989 cargo_target_dir: &Path,
2990 total_started: Instant,
2991 progress: &mut ProgressReporter<'_>,
2992 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
2993) -> Result<WasmBuildOutcome, WasmBuildError> {
2994 let targets = &resolved.wasm_outputs()?.targets;
2995 let fingerprint = resolved.fingerprint;
2996 let mut input_resolution = resolved.timings;
2997 let cache_entry = cache_entry_directory(spec, fingerprint);
2998 let preparation_started = Instant::now();
2999 progress.begin_phase(WasmBuildPhase::ArtifactPublication);
3000 let preparation_result = prepare_exact_cache_entry(&cache_entry);
3001 progress.record_phase(
3002 WasmBuildPhase::ArtifactPublication,
3003 preparation_started.elapsed(),
3004 );
3005 let incomplete_directory = preparation_result?;
3006 let build_result = (|| {
3007 if matches!(
3008 spec.cache_mode,
3009 WasmBuildCacheMode::SharedIncremental { .. }
3010 ) {
3011 record_cache_entry_use(cargo_target_dir)?;
3012 }
3013 let build_started = Instant::now();
3014 progress.begin_phase(WasmBuildPhase::CargoBuild);
3015 let cargo_result = run_cargo_build(spec, cargo_target_dir, targets, progress, &mut spawn);
3016 let cargo_build = build_started.elapsed();
3017 progress.record_phase(WasmBuildPhase::CargoBuild, cargo_build);
3018 let built_artifacts = cargo_result?;
3019 let validation_started = Instant::now();
3020 progress.begin_phase(WasmBuildPhase::ArtifactPublication);
3021 let missing = missing_artifacts(&built_artifacts);
3022 progress.record_phase(
3023 WasmBuildPhase::ArtifactPublication,
3024 validation_started.elapsed(),
3025 );
3026 if !missing.is_empty() {
3027 return Err(WasmBuildError::MissingArtifacts { paths: missing });
3028 }
3029
3030 let verified = verify_resolved_inputs(spec, resolved, progress)?;
3031 input_resolution.include(verified.timings);
3032
3033 let publication_guard = shared_incremental.lock_prepared_publication()?;
3038
3039 let paths = artifact_publication_paths(spec, &cache_entry, targets);
3040 progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
3041 copy_wasm_artifacts(&built_artifacts, &paths)?;
3042 let artifacts = publish_artifact_stamps(paths, fingerprint)?;
3043 materialize_artifacts(&artifacts, fingerprint, false)?;
3044 record_cache_entry_use(&cache_entry)
3045 })?;
3046 drop(publication_guard);
3047
3048 Ok(WasmBuildOutcome::Built(complete_build_record(
3049 spec,
3050 BuildRecordInput {
3051 resolved,
3052 lock_wait,
3053 shared_incremental,
3054 input_resolution,
3055 cargo_build: Some(cargo_build),
3056 active_entry: &cache_entry,
3057 targets,
3058 },
3059 total_started,
3060 progress,
3061 )?))
3062 })();
3063 finish_fingerprint_build(build_result, incomplete_directory, progress)
3064}
3065
3066pub fn prune_wasm_build_cache(
3078 target_dir: &Path,
3079 policy: CacheRetentionLimits,
3080) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3081 let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
3082 ensure_cache_directory_tag(target_dir)?;
3083
3084 prune_wasm_build_cache_locked(target_dir, policy, None)
3085}
3086
3087struct BuildRecordInput<'a> {
3088 resolved: &'a ResolvedCargoBuildInputs,
3089 lock_wait: Duration,
3090 shared_incremental: WasmAcquisitionContext,
3091 input_resolution: WasmInputResolutionTimings,
3092 cargo_build: Option<Duration>,
3093 active_entry: &'a Path,
3094 targets: &'a [WasmArtifactTarget],
3095}
3096
3097fn complete_build_record(
3098 spec: &WasmBuildSpec,
3099 input: BuildRecordInput<'_>,
3100 total_started: Instant,
3101 progress: &mut ProgressReporter<'_>,
3102) -> Result<WasmBuildRecord, WasmBuildError> {
3103 let retention = progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
3104 RetainedCacheEntry::acquire(input.active_entry).map_err(wasm_cache_fs_error)
3105 })?;
3106 let (maintenance, cache_maintenance) =
3107 spec.pruning.map_or((None, None), |(policy, interval)| {
3108 progress.run_phase(WasmBuildPhase::ExactCacheMaintenance, || {
3109 let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
3110 let identity = policy.maintenance_identity();
3111 perform_scheduled_cache_maintenance(&cache_root, interval, &identity, || {
3112 prune_wasm_build_cache_locked(
3113 &spec.target_dir,
3114 policy,
3115 Some(input.active_entry),
3116 )
3117 .map_err(|error| error.to_string())
3118 })
3119 })
3120 });
3121 Ok(WasmBuildRecord {
3122 facts: input.resolved.facts.clone(),
3123 fingerprint: input.resolved.fingerprint,
3124 input_digest: input.resolved.input_digest,
3125 artifacts: expected_artifacts(spec, input.active_entry, input.targets).into(),
3126 retention,
3127 timings: WasmBuildTimings {
3128 lock_wait: input.lock_wait,
3129 shared_incremental_lock_wait: input.shared_incremental.lock_wait,
3130 input_resolution: input.input_resolution,
3131 cargo_build: input.cargo_build,
3132 cache_maintenance,
3133 total: total_started.elapsed(),
3134 },
3135 maintenance,
3136 shared_incremental_maintenance: input.shared_incremental.maintenance,
3137 })
3138}
3139
3140fn prune_wasm_build_cache_locked(
3141 target_dir: &Path,
3142 policy: CacheRetentionLimits,
3143 protected_entry: Option<&Path>,
3144) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3145 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
3146 prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
3147 .map_err(wasm_cache_fs_error)
3148}
3149
3150fn finish_fingerprint_build<T>(
3151 result: Result<T, WasmBuildError>,
3152 incomplete_directory: OwnedDirectory,
3153 progress: &mut ProgressReporter<'_>,
3154) -> Result<T, WasmBuildError> {
3155 match result {
3156 Ok(outcome) => {
3157 incomplete_directory.preserve();
3158 Ok(outcome)
3159 }
3160 Err(build_error) => Err(cleanup_failed_fingerprint_build(
3161 build_error,
3162 incomplete_directory,
3163 progress,
3164 )),
3165 }
3166}
3167
3168fn cleanup_failed_fingerprint_build(
3169 build_error: WasmBuildError,
3170 incomplete_directory: OwnedDirectory,
3171 progress: &mut ProgressReporter<'_>,
3172) -> WasmBuildError {
3173 let path = incomplete_directory.path().to_owned();
3174 let primary_phase = progress.failure_phase;
3175 let cleanup_started = Instant::now();
3176 let cleanup = incomplete_directory.cleanup();
3177 progress.record_phase(WasmBuildPhase::Cleanup, cleanup_started.elapsed());
3178 match cleanup {
3179 Ok(()) => {
3180 progress.failure_phase = primary_phase;
3181 build_error
3182 }
3183 Err(source) => WasmBuildError::FailedBuildCleanup {
3184 build_error: Box::new(build_error),
3185 path,
3186 source,
3187 },
3188 }
3189}
3190
3191fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
3192 create_dir_all(target_dir, "create Cargo target directory")?;
3193 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3194 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)
3195}
3196
3197fn lock_wasm_build_cache_with_progress(
3198 target_dir: &Path,
3199 progress: &mut ProgressReporter<'_>,
3200) -> Result<(File, Duration), WasmBuildError> {
3201 progress.begin_phase(WasmBuildPhase::ExactCacheCoordination);
3202 create_dir_all(target_dir, "create Cargo target directory")?;
3203 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3204 lock_cache_file_with_progress(&lock_path, WasmBuildPhase::ExactCacheCoordination, progress)
3205}
3206
3207fn lock_shared_incremental_target(
3208 spec: &WasmBuildSpec,
3209) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3210 lock_shared_incremental_target_internal(spec, None)
3211}
3212
3213fn lock_shared_incremental_target_with_progress(
3214 spec: &WasmBuildSpec,
3215 progress: &mut ProgressReporter<'_>,
3216) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3217 let target_dir = shared_incremental_target(spec)
3218 .expect("validated shared acquisition must have a shared Cargo target");
3219 progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted { target_dir });
3220 let (lock, wait, canonical) = lock_shared_incremental_target_internal(spec, Some(progress))?;
3221 progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
3222 target_dir: canonical.clone(),
3223 wait,
3224 });
3225 Ok((lock, wait, canonical))
3226}
3227
3228fn lock_shared_incremental_target_internal(
3229 spec: &WasmBuildSpec,
3230 mut progress: Option<&mut ProgressReporter<'_>>,
3231) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3232 if let Some(progress) = progress.as_deref_mut() {
3233 progress.begin_phase(WasmBuildPhase::SharedTargetCoordination);
3234 }
3235 let target_dir =
3236 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
3237 message: "shared incremental target is not configured".to_owned(),
3238 })?;
3239 create_dir_all(
3240 &target_dir,
3241 "create shared incremental Cargo target directory",
3242 )?;
3243 ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
3244 let canonical = target_dir
3245 .canonicalize()
3246 .map_err(|source| WasmBuildError::Io {
3247 operation: "resolve shared incremental Cargo target directory",
3248 path: target_dir.clone(),
3249 source,
3250 })?;
3251 let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
3252 let (lock, wait) = if let Some(progress) = progress {
3253 lock_cache_file_with_progress(
3254 &lock_path,
3255 WasmBuildPhase::SharedTargetCoordination,
3256 progress,
3257 )?
3258 } else {
3259 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)?
3260 };
3261 Ok((lock, wait, canonical))
3262}
3263
3264fn lock_cache_file_with_progress(
3265 lock_path: &Path,
3266 phase: WasmBuildPhase,
3267 progress: &mut ProgressReporter<'_>,
3268) -> Result<(File, Duration), WasmBuildError> {
3269 let started = Instant::now();
3270 progress.begin_phase(phase);
3271 let result = if !progress.is_observed() || progress.config.heartbeat_interval.is_none() {
3272 lock_cache_file(lock_path).map_err(wasm_cache_fs_error)
3273 } else {
3274 let heartbeat_interval = progress
3275 .config
3276 .heartbeat_interval
3277 .expect("observed cache lock must have a heartbeat interval");
3278 lock_cache_file_with_wait_observer(lock_path, heartbeat_interval, |elapsed| {
3279 progress.emit_heartbeat_if_due(phase, elapsed);
3280 })
3281 .map_err(wasm_cache_fs_error)
3282 };
3283 progress.record_phase(phase, started.elapsed());
3284 result
3285}
3286
3287fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
3288 ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
3289}
3290
3291fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
3292 record_entry_use(path).map_err(wasm_cache_fs_error)
3293}
3294
3295fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
3296 WasmBuildError::Io {
3297 operation: error.operation,
3298 path: error.path,
3299 source: error.source,
3300 }
3301}
3302
3303fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3304 for (key, value) in &spec.environment {
3305 let key = key.as_encoded_bytes();
3306 if key.is_empty()
3307 || key.iter().any(|byte| matches!(byte, b'=' | 0))
3308 || value.as_encoded_bytes().contains(&0)
3309 {
3310 return Err(WasmBuildError::InvalidSpec {
3311 message: "invalid child environment name or value".to_owned(),
3312 });
3313 }
3314 }
3315 if spec.cargo_message_bytes == 0 {
3316 return Err(WasmBuildError::InvalidSpec {
3317 message: "Cargo message allowance must be nonzero".to_owned(),
3318 });
3319 }
3320 if spec.packages.is_empty() {
3321 return Err(WasmBuildError::InvalidSpec {
3322 message: "at least one Cargo package is required".to_owned(),
3323 });
3324 }
3325 if spec.target.is_empty() {
3326 return Err(WasmBuildError::InvalidSpec {
3327 message: "Cargo compilation target must not be empty".to_owned(),
3328 });
3329 }
3330 if spec
3331 .environment
3332 .contains_key(OsStr::new("CARGO_TARGET_DIR"))
3333 {
3334 return Err(WasmBuildError::InvalidSpec {
3335 message: "Cargo target directories are owned by the build specification; use target_dir or with_shared_incremental_target instead of command overrides".to_owned(),
3336 });
3337 }
3338 validate_cargo_target_selection(spec)?;
3339 spec.cargo_selection
3340 .profile_target_dir()
3341 .map_err(|message| WasmBuildError::InvalidSpec {
3342 message: message.to_owned(),
3343 })?;
3344 if matches!(
3345 &spec.cache_mode,
3346 WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
3347 ) {
3348 return Err(WasmBuildError::InvalidSpec {
3349 message: "shared incremental Cargo target directory must not be empty".to_owned(),
3350 });
3351 }
3352 if spec.shared_incremental_maintenance_config.is_some()
3353 && !matches!(
3354 spec.cache_mode,
3355 WasmBuildCacheMode::SharedIncremental { .. }
3356 )
3357 {
3358 return Err(WasmBuildError::InvalidSpec {
3359 message:
3360 "scheduled shared-target maintenance requires a shared incremental Cargo target"
3361 .to_owned(),
3362 });
3363 }
3364 Ok(())
3365}
3366
3367fn validate_cargo_target_selection(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3368 if let Some(example) = &spec.example {
3369 let mut components = Path::new(example).components();
3370 if spec.packages.len() != 1
3371 || !matches!(
3372 (components.next(), components.next()),
3373 (Some(Component::Normal(_)), None)
3374 )
3375 {
3376 return Err(WasmBuildError::InvalidSpec {
3377 message: "a named Cargo example requires one package, a normal target name"
3378 .to_owned(),
3379 });
3380 }
3381 }
3382 if spec.packages.iter().any(|package| {
3383 let mut components = Path::new(package).components();
3384 !matches!(
3385 (components.next(), components.next()),
3386 (Some(Component::Normal(_)), None)
3387 )
3388 }) {
3389 return Err(WasmBuildError::InvalidSpec {
3390 message: "Cargo package names must be normal path components".to_owned(),
3391 });
3392 }
3393 Ok(())
3394}
3395
3396fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3397 build_fingerprint_with_progress(spec, &mut ProgressReporter::silent())
3398}
3399
3400fn build_fingerprint_with_progress(
3401 spec: &WasmBuildSpec,
3402 progress: &mut ProgressReporter<'_>,
3403) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3404 build_fingerprint_with_policy(spec, progress, &mut None)
3405}
3406
3407fn build_fingerprint_with_policy(
3408 spec: &WasmBuildSpec,
3409 progress: &mut ProgressReporter<'_>,
3410 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3411) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3412 let total_started = Instant::now();
3413 let (cargo_identity, rustc_identity, tool_identity) =
3414 resolve_tool_identity_with_policy(spec, progress, policy)?;
3415
3416 let metadata_started = Instant::now();
3417 let metadata = progress.run_phase(WasmBuildPhase::CargoMetadata, || {
3418 cargo_metadata_with_policy(spec, policy)
3419 })?;
3420 let cargo_metadata = metadata_started.elapsed();
3421
3422 let discovery_started = Instant::now();
3423 let (inputs, exclusions) = progress.run_phase(WasmBuildPhase::InputDiscovery, || {
3424 let parsed =
3425 ParsedCargoMetadata::parse(&metadata).map_err(|message| invalid_metadata(&message))?;
3426 resolve_local_inputs(spec, &parsed)
3427 })?;
3428 let input_discovery = discovery_started.elapsed();
3429
3430 let hashing_started = Instant::now();
3431 let (input_digest, validation_digest) =
3432 progress.run_phase(WasmBuildPhase::ContentHashing, || {
3433 let mut cache = LabeledPathDigestCache::default();
3434 digest_resolved_local_inputs(
3435 &inputs,
3436 &exclusions,
3437 &mut cache,
3438 &spec.workspace_root,
3439 "hash Wasm build inputs",
3440 "hash semantic Wasm build inputs",
3441 )
3442 })?;
3443 let content_hashing = hashing_started.elapsed();
3444
3445 Ok(ResolvedCargoBuildInputs::assemble(
3446 spec,
3447 inputs,
3448 exclusions,
3449 input_digest,
3450 validation_digest,
3451 spec.cargo_invocation()
3452 .facts(cargo_identity, rustc_identity),
3453 || WasmInputResolutionTimings {
3454 tool_identity,
3455 cargo_metadata,
3456 input_discovery,
3457 content_hashing,
3458 total: total_started.elapsed(),
3459 },
3460 ))
3461}
3462
3463fn cargo_build_inputs(inputs: Vec<(PathBuf, PathBuf)>) -> Arc<[CargoBuildInput]> {
3464 inputs
3465 .into_iter()
3466 .map(|(label, path)| CargoBuildInput { label, path })
3467 .collect()
3468}
3469
3470fn finish_build_fingerprint(
3471 spec: &WasmBuildSpec,
3472 facts: &CargoBuildFacts,
3473 input_digest: InputDigest,
3474) -> InputDigest {
3475 let mut hasher = InputHasher::new(BUILD_FINGERPRINT_DOMAIN);
3476 for package in &spec.packages {
3477 hasher.field("package", package.as_bytes());
3478 }
3479 hasher.field("target", facts.target.as_bytes());
3480 if let Some(example) = &spec.example {
3481 hasher.field("cargo-target-selection", b"example");
3482 hasher.field("cargo-example", example.as_bytes());
3483 } else {
3484 hasher.field("cargo-target-selection", b"lib");
3485 }
3486 hasher.field(
3487 "profile-target-dir",
3488 facts
3489 .selection
3490 .profile_target_dir()
3491 .expect("Cargo profile was validated before fingerprinting")
3492 .as_bytes(),
3493 );
3494 for argument in &facts.selection.build_arguments() {
3495 hasher.field("cargo-argument", &os_bytes(argument));
3496 }
3497 for (key, value) in &facts.environment {
3498 hasher.field("environment-key", &os_bytes(key));
3499 hasher.field("environment-value", &os_bytes(value));
3500 }
3501 hasher.field("cargo-identity", &facts.cargo_identity);
3502 hasher.field("rustc-identity", &facts.rustc_identity);
3503 hasher.field("source-input-digest", input_digest.as_bytes());
3504 hasher.finish()
3505}
3506
3507#[cfg(test)]
3508fn command_identity(
3509 spec: &WasmBuildSpec,
3510 phase: WasmBuildPhase,
3511 program: &OsStr,
3512 arguments: &[&str],
3513) -> Result<Vec<u8>, WasmBuildError> {
3514 command_identity_with_policy(spec, phase, program, arguments, &mut None)
3515}
3516
3517#[cfg(test)]
3518fn command_identity_with_policy(
3519 spec: &WasmBuildSpec,
3520 phase: WasmBuildPhase,
3521 program: &OsStr,
3522 arguments: &[&str],
3523 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3524) -> Result<Vec<u8>, WasmBuildError> {
3525 crate::cargo::execution::command_identity(
3526 program,
3527 &spec.workspace_root,
3528 &spec.environment,
3529 arguments,
3530 |command| {
3531 let output =
3532 capture_probe_output_with_policy(command, phase, TOOL_IDENTITY_BYTES, policy)?;
3533 ensure_command_success(phase, output)
3534 },
3535 )
3536}
3537
3538fn cargo_metadata(spec: &WasmBuildSpec) -> Result<schema::Metadata, WasmBuildError> {
3539 cargo_metadata_with_policy(spec, &mut None)
3540}
3541
3542fn cargo_metadata_with_policy(
3543 spec: &WasmBuildSpec,
3544 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3545) -> Result<schema::Metadata, WasmBuildError> {
3546 let mut command = Command::new(&spec.cargo_program);
3547 command
3548 .current_dir(&spec.workspace_root)
3549 .args(["metadata", "--format-version", "1"]);
3550 for argument in spec.cargo_selection.metadata_arguments() {
3551 command.arg(argument);
3552 }
3553 apply_command_environment(&mut command, spec);
3554 let output = capture_probe_output_with_policy(
3555 &mut command,
3556 WasmBuildPhase::CargoMetadata,
3557 CARGO_METADATA_BYTES,
3558 policy,
3559 )?;
3560 let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
3561 crate::cargo::observation::decode_metadata(&output.stdout).map_err(|error| {
3562 WasmBuildError::InvalidMetadata {
3563 message: format!("Cargo metadata did not match its schema: {error}"),
3564 }
3565 })
3566}
3567
3568#[cfg(test)]
3569fn capture_probe_output(
3570 command: &mut Command,
3571 phase: WasmBuildPhase,
3572 stdout_bytes: usize,
3573) -> Result<CompletedExecution, WasmBuildError> {
3574 capture_probe_output_with_policy(command, phase, stdout_bytes, &mut None)
3575}
3576
3577fn capture_probe_output_with_policy(
3578 command: &mut Command,
3579 phase: WasmBuildPhase,
3580 stdout_bytes: usize,
3581 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3582) -> Result<CompletedExecution, WasmBuildError> {
3583 let limits = OutputLimits {
3584 stdout: OutputLimit::Terminate(stdout_bytes),
3585 stderr: OutputLimit::Terminate(TOOL_IDENTITY_BYTES),
3586 timeout: None,
3587 };
3588 let result = if let Some(policy) = policy {
3589 capture_group_command_with_cancellation(
3590 command,
3591 limits,
3592 policy.cleanup,
3593 policy.deadline,
3594 &mut *policy.cancelled,
3595 )
3596 } else {
3597 capture_command(command, limits)
3598 }
3599 .and_then(CompletedExecution::require_complete);
3600 let evidence = match result {
3601 Ok(evidence) => evidence,
3602 Err(ToolError::Execution(error))
3603 if matches!(
3604 error.failure,
3605 ExecutionFailure::Io {
3606 operation: ExecutionOperation::Spawn,
3607 ..
3608 }
3609 ) && error.cleanup.is_none() =>
3610 {
3611 let ExecutionFailure::Io { source, .. } = error.failure else {
3612 unreachable!()
3613 };
3614 return Err(WasmBuildError::CommandSpawn {
3615 phase,
3616 program: command.get_program().to_owned(),
3617 source,
3618 });
3619 }
3620 Err(error) => {
3621 return Err(WasmBuildError::Io {
3622 operation: match phase {
3623 WasmBuildPhase::CargoIdentity => "capture complete Cargo identity probe output",
3624 WasmBuildPhase::RustcIdentity => "capture complete rustc identity probe output",
3625 _ => "capture complete Cargo metadata probe output",
3626 },
3627 path: PathBuf::from(command.get_program()),
3628 source: io::Error::other(error),
3629 });
3630 }
3631 };
3632 Ok(evidence)
3633}
3634
3635struct ParsedCargoMetadata<'a> {
3638 packages: HashMap<&'a str, &'a schema::Package>,
3639 workspace_packages: HashMap<&'a str, Option<&'a str>>,
3640 nodes: HashMap<&'a str, &'a schema::Node>,
3641 workspace_root: &'a Path,
3642}
3643
3644impl<'a> ParsedCargoMetadata<'a> {
3645 fn parse(metadata: &'a schema::Metadata) -> Result<Self, String> {
3646 let packages: HashMap<_, _> = metadata
3647 .packages
3648 .iter()
3649 .map(|package| (package.id.repr.as_str(), package))
3650 .collect();
3651 let workspace_members: HashSet<_> = metadata
3652 .workspace_members
3653 .iter()
3654 .map(|id| id.repr.as_str())
3655 .collect();
3656 let mut workspace_packages = HashMap::new();
3657 for package in packages
3658 .values()
3659 .filter(|package| workspace_members.contains(package.id.repr.as_str()))
3660 {
3661 workspace_packages
3662 .entry(package.name.as_str())
3663 .and_modify(|id| *id = None)
3664 .or_insert(Some(package.id.repr.as_str()));
3665 }
3666 let resolve = metadata
3667 .resolve
3668 .as_ref()
3669 .ok_or_else(|| "Cargo metadata has no resolved dependency nodes".to_owned())?;
3670 let nodes = resolve
3671 .nodes
3672 .iter()
3673 .map(|node| (node.id.repr.as_str(), node))
3674 .collect();
3675 Ok(Self {
3676 packages,
3677 workspace_packages,
3678 nodes,
3679 workspace_root: metadata.workspace_root.as_std_path(),
3680 })
3681 }
3682}
3683
3684struct LockedPackageIdentity {
3685 name: String,
3686 version: String,
3687 source: String,
3688 checksum: Option<String>,
3689}
3690
3691type LockedPackageIndex<'a> = HashMap<(&'a str, &'a str, &'a str), &'a LockedPackageIdentity>;
3692
3693fn index_locked_packages(packages: &[LockedPackageIdentity]) -> LockedPackageIndex<'_> {
3694 let mut index = HashMap::with_capacity(packages.len());
3695 for package in packages {
3696 index
3698 .entry((
3699 package.name.as_str(),
3700 package.version.as_str(),
3701 package.source.as_str(),
3702 ))
3703 .or_insert(package);
3704 }
3705 index
3706}
3707
3708fn resolve_local_inputs(
3709 spec: &WasmBuildSpec,
3710 metadata: &ParsedCargoMetadata<'_>,
3711) -> Result<(ResolvedLocalInputs, Vec<PathBuf>), WasmBuildError> {
3712 let mut selected_ids = selected_package_ids(spec, metadata)?;
3713 let mut artifact_names = BTreeSet::new();
3716 let wasm_targets = selected_ids
3717 .iter()
3718 .map(|id| {
3719 let package = metadata.packages[id];
3720 let mut matching = package.targets.iter().filter(|target| {
3721 target.crate_types.contains(&schema::CrateType::CDyLib)
3722 && spec.example.as_ref().map_or_else(
3723 || target.is_cdylib(),
3724 |example| target.name == *example && target.is_example(),
3725 )
3726 });
3727 let Some(target) = matching.next() else {
3728 return Err(format!(
3729 "Cargo package `{}` must declare the selected cdylib target",
3730 package.name
3731 ));
3732 };
3733 let mut components = Path::new(&target.name).components();
3734 if matching.next().is_some()
3735 || !matches!(
3736 (components.next(), components.next()),
3737 (Some(Component::Normal(_)), None)
3738 )
3739 {
3740 return Err(format!(
3741 "Cargo package `{}` has an ambiguous or unsafe cdylib target",
3742 package.name
3743 ));
3744 }
3745 let cache_name = target.name.replace('-', "_");
3746 if !artifact_names.insert(cache_name.clone()) {
3747 return Err(format!(
3748 "selected Cargo packages share the Wasm artifact name `{cache_name}`"
3749 ));
3750 }
3751 Ok(WasmArtifactTarget {
3752 package_id: package.id.repr.clone(),
3753 name: target.name.clone(),
3754 kind: if spec.example.is_some() {
3755 schema::TargetKind::Example
3756 } else {
3757 schema::TargetKind::CDyLib
3758 },
3759 })
3760 })
3761 .collect();
3762 let mut closure = BTreeSet::new();
3763 while let Some(id) = selected_ids.pop_front() {
3764 if !closure.insert(id) {
3765 continue;
3766 }
3767 if let Some(node) = metadata.nodes.get(id) {
3768 selected_ids.extend(
3769 node.deps
3770 .iter()
3771 .map(|dependency| dependency.pkg.repr.as_str()),
3772 );
3773 }
3774 }
3775
3776 let workspace_root = metadata.workspace_root;
3777 let workspace_projection = semantic_workspace_projection(metadata, &closure, workspace_root)?;
3778 let mut validation_inputs = workspace_configuration_inputs(spec, workspace_root)?;
3779 append_package_inputs(
3780 &mut validation_inputs,
3781 &metadata.packages,
3782 closure,
3783 workspace_root,
3784 )?;
3785 append_additional_inputs(&mut validation_inputs, spec, workspace_root);
3786 validate_shared_incremental_target_boundary(spec, &validation_inputs)?;
3787 let exclusions = source_exclusions(spec, &validation_inputs);
3788 Ok((
3789 ResolvedLocalInputs {
3790 validation_inputs,
3791 workspace_projection,
3792 wasm_targets,
3793 },
3794 exclusions,
3795 ))
3796}
3797
3798fn selected_package_ids<'a>(
3799 spec: &WasmBuildSpec,
3800 metadata: &ParsedCargoMetadata<'a>,
3801) -> Result<VecDeque<&'a str>, WasmBuildError> {
3802 let mut selected_ids = VecDeque::new();
3803 for requested in &spec.packages {
3804 match metadata.workspace_packages.get(requested.as_str()) {
3805 Some(Some(id)) => selected_ids.push_back(*id),
3806 None => {
3807 return Err(WasmBuildError::InvalidSpec {
3808 message: format!("Cargo workspace contains no package named `{requested}`"),
3809 });
3810 }
3811 Some(None) => {
3812 return Err(WasmBuildError::InvalidSpec {
3813 message: format!("Cargo workspace package name `{requested}` is ambiguous"),
3814 });
3815 }
3816 }
3817 }
3818 Ok(selected_ids)
3819}
3820
3821fn semantic_workspace_projection(
3822 metadata: &ParsedCargoMetadata<'_>,
3823 closure: &BTreeSet<&str>,
3824 workspace_root: &Path,
3825) -> Result<Option<InputDigest>, WasmBuildError> {
3826 let locked_packages = locked_package_identities(workspace_root)?;
3829 let locked_index = index_locked_packages(&locked_packages);
3830 let mut identities = HashMap::new();
3831 for &id in closure {
3832 let package = metadata
3833 .packages
3834 .get(id)
3835 .ok_or_else(|| invalid_metadata(&format!("resolved package `{id}` is missing")))?;
3836 let Some(identity) = semantic_package_identity(package, workspace_root, &locked_index)
3837 else {
3838 return Ok(None);
3839 };
3840 identities.insert(id, identity);
3841 }
3842
3843 let mut projected_packages = closure
3844 .iter()
3845 .map(|&id| {
3846 let package = metadata
3847 .packages
3848 .get(id)
3849 .expect("selected package closure was validated above");
3850 let identity = identities[id];
3851 let node = metadata.nodes.get(id).ok_or_else(|| {
3852 invalid_metadata(&format!("resolved package `{id}` has no dependency node"))
3853 })?;
3854 let projection = semantic_package_projection(package, node, &identities)?;
3855 Ok::<_, WasmBuildError>((identity, projection))
3856 })
3857 .collect::<Result<Vec<_>, _>>()?;
3858 projected_packages.sort_by_key(|(identity, _)| *identity);
3859
3860 let root_manifest = workspace_root.join("Cargo.toml");
3861 let root_contents =
3862 fs::read_to_string(&root_manifest).map_err(|source| WasmBuildError::Io {
3863 operation: "read workspace manifest for semantic projection",
3864 path: root_manifest.clone(),
3865 source,
3866 })?;
3867 let root = toml::from_str::<TomlValue>(&root_contents).map_err(|error| {
3868 invalid_metadata(&format!(
3869 "workspace manifest could not be projected as TOML: {error}"
3870 ))
3871 })?;
3872
3873 let mut hasher = InputHasher::new("wasm-semantic-workspace-projection-v1");
3874 for (identity, projection) in projected_packages {
3875 hasher.field("package-identity", identity.as_bytes());
3876 hasher.field("package-projection", projection.as_bytes());
3877 }
3878 hash_toml_setting(&mut hasher, "cargo-features", root.get("cargo-features"));
3879 hash_toml_setting(&mut hasher, "profile", root.get("profile"));
3880 let workspace = root.get("workspace").and_then(TomlValue::as_table);
3881 hash_toml_setting(
3882 &mut hasher,
3883 "workspace-resolver",
3884 workspace.and_then(|table| table.get("resolver")),
3885 );
3886 hash_toml_setting(
3887 &mut hasher,
3888 "workspace-lints",
3889 workspace.and_then(|table| table.get("lints")),
3890 );
3891 Ok(Some(hasher.finish()))
3892}
3893
3894fn locked_package_identities(
3895 workspace_root: &Path,
3896) -> Result<Vec<LockedPackageIdentity>, WasmBuildError> {
3897 use crate::cargo::lockfile::{external_package, optional_string, required_string};
3898 let lockfile = workspace_root.join("Cargo.lock");
3899 let contents = match fs::read_to_string(&lockfile) {
3900 Ok(contents) => contents,
3901 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
3902 Err(source) => {
3903 return Err(WasmBuildError::Io {
3904 operation: "read Cargo lockfile for semantic projection",
3905 path: lockfile,
3906 source,
3907 });
3908 }
3909 };
3910 let lock = toml::from_str::<TomlValue>(&contents).map_err(|error| {
3911 invalid_metadata(&format!(
3912 "Cargo lockfile could not be projected as TOML: {error}"
3913 ))
3914 })?;
3915 let Some(packages) = lock.get("package").and_then(TomlValue::as_array) else {
3916 return Ok(Vec::new());
3917 };
3918 packages
3919 .iter()
3920 .filter_map(|package| {
3921 let table = match external_package(package) {
3922 Ok(Some(table)) => table,
3923 Ok(None) => return None,
3924 Err(message) => {
3925 return Some(Err(invalid_metadata(&format!("Cargo lockfile {message}"))));
3926 }
3927 };
3928 let text = |field| {
3929 required_string(table, field)
3930 .map(str::to_owned)
3931 .map_err(|_| {
3932 invalid_metadata(&format!(
3933 "Cargo lockfile package `{field}` is missing or not a string"
3934 ))
3935 })
3936 };
3937 let source = required_string(table, "source").map(str::to_owned);
3938 Some(
3939 source
3940 .map_err(|_| invalid_metadata("Cargo lockfile package source is not a string"))
3941 .and_then(|source| {
3942 Ok(LockedPackageIdentity {
3943 name: text("name")?,
3944 version: text("version")?,
3945 source,
3946 checksum: optional_string(table, "checksum")
3947 .map_err(|_| {
3948 invalid_metadata(
3949 "Cargo lockfile package `checksum` is not a string",
3950 )
3951 })?
3952 .map(str::to_owned),
3953 })
3954 }),
3955 )
3956 })
3957 .collect()
3958}
3959
3960fn semantic_package_identity(
3961 package: &schema::Package,
3962 workspace_root: &Path,
3963 locked_packages: &LockedPackageIndex<'_>,
3964) -> Option<InputDigest> {
3965 let version = package.version.to_string();
3966 let mut hasher = InputHasher::new("wasm-semantic-package-identity-v1");
3967 hasher.field("name", package.name.as_bytes());
3968 hasher.field("version", version.as_bytes());
3969 if package.source.is_none() {
3970 let manifest = package.manifest_path.strip_prefix(workspace_root).ok()?;
3971 let package_root = package.manifest_path.parent()?;
3972 if package_root == workspace_root {
3973 return None;
3974 }
3975 hasher.field("local-manifest", &os_bytes(manifest.as_os_str()));
3976 } else {
3977 let metadata_source = &package.source.as_ref()?.repr;
3978 let locked = locked_packages.get(&(
3979 package.name.as_str(),
3980 version.as_str(),
3981 metadata_source.as_str(),
3982 ))?;
3983 match locked.source.as_str() {
3984 source if source.starts_with("registry+") && locked.checksum.is_some() => {}
3985 source if source.starts_with("git+") && source.contains('#') => {}
3986 _ => return None,
3987 }
3988 hasher.field("external-package-id", package.id.repr.as_bytes());
3989 hasher.field("external-source", locked.source.as_bytes());
3990 hasher.field(
3991 "external-checksum",
3992 locked.checksum.as_deref().unwrap_or_default().as_bytes(),
3993 );
3994 }
3995 Some(hasher.finish())
3996}
3997
3998fn semantic_package_projection(
3999 package: &schema::Package,
4000 node: &schema::Node,
4001 identities: &HashMap<&str, InputDigest>,
4002) -> Result<InputDigest, WasmBuildError> {
4003 let mut hasher = InputHasher::new("wasm-semantic-package-projection-v2");
4007 macro_rules! fields {
4010 ($($field:ident),* $(,)?) => {$(
4011 hasher.field("package-field-name", stringify!($field).as_bytes());
4012 hasher.field("package-field-value", &serde_json::to_vec(&package.$field)
4013 .expect("serializing Cargo fields into a Vec cannot fail"));
4014 )*};
4015 }
4016 fields!(
4017 authors,
4018 default_run,
4019 description,
4020 documentation,
4021 edition,
4022 homepage,
4023 license,
4024 license_file,
4025 links,
4026 metadata,
4027 name,
4028 readme,
4029 repository,
4030 rust_version,
4031 version
4032 );
4033
4034 let mut features = node
4035 .features
4036 .iter()
4037 .map(|feature| feature.as_str())
4038 .collect::<Vec<_>>();
4039 features.sort_unstable();
4040 for feature in features {
4041 hasher.field("enabled-feature", feature.as_bytes());
4042 }
4043
4044 let mut dependencies = node
4045 .deps
4046 .iter()
4047 .map(|dependency| {
4048 if dependency.dep_kinds.iter().any(|kind| {
4049 !matches!(
4050 kind.kind,
4051 schema::DependencyKind::Normal
4052 | schema::DependencyKind::Development
4053 | schema::DependencyKind::Build
4054 )
4055 }) {
4056 return Err(invalid_metadata("unsupported Cargo dependency kind"));
4057 }
4058 let name = dependency.name.as_str();
4059 let package_id = dependency.pkg.repr.as_str();
4060 let identity = identities.get(package_id).copied().ok_or_else(|| {
4061 invalid_metadata(&format!(
4062 "dependency `{package_id}` is outside the selected package closure"
4063 ))
4064 })?;
4065 let kinds = serde_json::to_vec(&dependency.dep_kinds)
4066 .expect("serializing Cargo dependency kinds into a Vec cannot fail");
4067 Ok::<_, WasmBuildError>((name, identity, kinds))
4068 })
4069 .collect::<Result<Vec<_>, _>>()?;
4070 dependencies.sort();
4071 for (name, identity, kinds) in dependencies {
4072 hasher.field("dependency-name", name.as_bytes());
4073 hasher.field("dependency-identity", identity.as_bytes());
4074 hasher.field("dependency-kinds", &kinds);
4075 }
4076 Ok(hasher.finish())
4077}
4078
4079fn hash_toml_setting(hasher: &mut InputHasher, label: &str, value: Option<&TomlValue>) {
4080 hasher.field("workspace-setting-name", label.as_bytes());
4081 match value {
4082 Some(value) => hasher.field("workspace-setting-value", value.to_string().as_bytes()),
4083 None => hasher.field("workspace-setting-missing", b""),
4084 }
4085}
4086
4087fn is_broad_workspace_input(label: &Path) -> bool {
4088 label == Path::new("workspace/Cargo.toml") || label == Path::new("workspace/Cargo.lock")
4089}
4090
4091fn digest_resolved_local_inputs(
4092 inputs: &ResolvedLocalInputs,
4093 exclusions: &[PathBuf],
4094 cache: &mut LabeledPathDigestCache,
4095 error_path: &Path,
4096 validation_operation: &'static str,
4097 semantic_operation: &'static str,
4098) -> Result<(InputDigest, InputDigest), WasmBuildError> {
4099 let validation_digest = digest_labeled_paths_composable(
4100 "wasm-source-inputs-v1",
4101 inputs
4102 .validation_inputs
4103 .iter()
4104 .map(|(label, path)| (label.as_path(), path.as_path())),
4105 exclusions,
4106 cache,
4107 )
4108 .map_err(|source| WasmBuildError::Io {
4109 operation: validation_operation,
4110 path: error_path.to_owned(),
4111 source,
4112 })?;
4113 let input_digest = semantic_input_digest(inputs, validation_digest, exclusions, cache)
4114 .map_err(|source| WasmBuildError::Io {
4115 operation: semantic_operation,
4116 path: error_path.to_owned(),
4117 source,
4118 })?;
4119 Ok((input_digest, validation_digest))
4120}
4121
4122fn semantic_input_digest(
4123 inputs: &ResolvedLocalInputs,
4124 validation_digest: InputDigest,
4125 exclusions: &[PathBuf],
4126 cache: &mut LabeledPathDigestCache,
4127) -> io::Result<InputDigest> {
4128 let Some(workspace) = inputs.workspace_projection else {
4129 return Ok(validation_digest);
4130 };
4131 let path_digest = digest_labeled_paths_composable(
4132 "wasm-source-inputs-v1",
4133 inputs
4134 .validation_inputs
4135 .iter()
4136 .filter(|(label, _)| !is_broad_workspace_input(label))
4137 .map(|(label, path)| (label.as_path(), path.as_path())),
4138 exclusions,
4139 cache,
4140 )?;
4141 let mut hasher = InputHasher::new("wasm-semantic-source-inputs-v1");
4142 hasher.field("path-input-digest", path_digest.as_bytes());
4143 hasher.field("workspace-projection", workspace.as_bytes());
4144 Ok(hasher.finish())
4145}
4146
4147fn workspace_configuration_inputs(
4148 spec: &WasmBuildSpec,
4149 workspace_root: &Path,
4150) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
4151 let mut inputs = Vec::new();
4152 add_if_present(
4153 &mut inputs,
4154 "workspace/Cargo.toml",
4155 workspace_root.join("Cargo.toml"),
4156 );
4157 add_if_present(
4158 &mut inputs,
4159 "workspace/Cargo.lock",
4160 workspace_root.join("Cargo.lock"),
4161 );
4162 add_if_present(
4163 &mut inputs,
4164 "workspace/rust-toolchain.toml",
4165 workspace_root.join("rust-toolchain.toml"),
4166 );
4167 add_if_present(
4168 &mut inputs,
4169 "workspace/rust-toolchain",
4170 workspace_root.join("rust-toolchain"),
4171 );
4172 append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
4173 Ok(inputs)
4174}
4175
4176fn append_cargo_configuration_inputs(
4177 inputs: &mut Vec<(PathBuf, PathBuf)>,
4178 spec: &WasmBuildSpec,
4179 workspace_root: &Path,
4180) -> Result<(), WasmBuildError> {
4181 let invocation_root =
4182 spec.workspace_root
4183 .canonicalize()
4184 .map_err(|source| WasmBuildError::Io {
4185 operation: "resolve Cargo invocation directory",
4186 path: spec.workspace_root.clone(),
4187 source,
4188 })?;
4189 let canonical_workspace =
4190 workspace_root
4191 .canonicalize()
4192 .map_err(|source| WasmBuildError::Io {
4193 operation: "resolve Cargo workspace directory",
4194 path: workspace_root.to_owned(),
4195 source,
4196 })?;
4197
4198 let cargo_home = command_environment_value(spec, "CARGO_HOME");
4199 let default_home = default_home_directory(spec);
4200 let directories = crate::cargo::environment::configuration_directories(
4201 &invocation_root,
4202 cargo_home.as_deref(),
4203 default_home.as_deref().map(Path::as_os_str),
4204 )
4205 .map_err(|source| WasmBuildError::Io {
4206 operation: "resolve Cargo configuration directories",
4207 path: invocation_root.clone(),
4208 source,
4209 })?;
4210 let roots: Vec<_> = directories
4211 .iter()
4212 .filter_map(|directory| crate::cargo::environment::preferred_configuration(directory))
4213 .collect();
4214 let observed =
4215 crate::cargo::environment::observe_configurations(&roots).map_err(|error| match error {
4216 crate::cargo::environment::ConfigurationError::Io {
4217 operation,
4218 path,
4219 source,
4220 } => WasmBuildError::Io {
4221 operation,
4222 path,
4223 source,
4224 },
4225 crate::cargo::environment::ConfigurationError::Invalid { path, message } => {
4226 WasmBuildError::InvalidCargoConfiguration { path, message }
4227 }
4228 })?;
4229 for config in observed {
4230 if !inputs.iter().any(|(_, path)| path == &config.path) {
4231 inputs.push((
4232 cargo_configuration_label(&config.location, &canonical_workspace),
4233 config.path,
4234 ));
4235 }
4236 }
4237 Ok(())
4238}
4239
4240#[cfg(windows)]
4241fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4242 command_environment_value(spec, "USERPROFILE")
4243 .or_else(|| command_environment_value(spec, "HOME"))
4244 .map(PathBuf::from)
4245}
4246
4247#[cfg(not(windows))]
4248fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4249 command_environment_value(spec, "HOME").map(PathBuf::from)
4250}
4251
4252fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
4253 spec.environment.get(OsStr::new(name)).cloned()
4254}
4255
4256fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
4257 if let Ok(relative) = config.strip_prefix(workspace_root) {
4258 return PathBuf::from("cargo-config/workspace").join(relative);
4259 }
4260 let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
4261 PathBuf::from("cargo-config/external").join(location.to_hex())
4262}
4263
4264fn append_package_inputs(
4265 inputs: &mut Vec<(PathBuf, PathBuf)>,
4266 packages: &HashMap<&str, &schema::Package>,
4267 closure: BTreeSet<&str>,
4268 workspace_root: &Path,
4269) -> Result<(), WasmBuildError> {
4270 for id in closure {
4271 let Some(package) = packages.get(id) else {
4272 return Err(invalid_metadata(&format!(
4273 "resolved package `{id}` is missing"
4274 )));
4275 };
4276 if package.source.is_some() {
4277 continue;
4278 }
4279 let root = package.manifest_path.parent().ok_or_else(|| {
4280 invalid_metadata(&format!(
4281 "package `{}` manifest has no parent",
4282 package.name
4283 ))
4284 })?;
4285 let relative_manifest = package
4286 .manifest_path
4287 .as_std_path()
4288 .strip_prefix(workspace_root)
4289 .unwrap_or(package.manifest_path.as_std_path());
4290 let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
4291 .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
4292 inputs.push((label, root.as_std_path().to_owned()));
4293 }
4294 Ok(())
4295}
4296
4297fn append_additional_inputs(
4298 inputs: &mut Vec<(PathBuf, PathBuf)>,
4299 spec: &WasmBuildSpec,
4300 workspace_root: &Path,
4301) {
4302 for additional in &spec.additional_inputs {
4303 let path = if additional.is_absolute() {
4304 additional.clone()
4305 } else {
4306 workspace_root.join(additional)
4307 };
4308 inputs.push((PathBuf::from("additional").join(additional), path));
4309 }
4310}
4311
4312fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
4313 let mut exclusions = vec![
4314 spec.target_dir.clone(),
4315 spec.workspace_root.join("target"),
4316 spec.workspace_root.join(".git"),
4317 ];
4318 if let Some(shared_target) = shared_incremental_target(spec) {
4319 exclusions.push(shared_target);
4320 }
4321 for (_, path) in inputs {
4322 if path.is_dir() {
4323 exclusions.push(path.join("target"));
4324 exclusions.push(path.join(".git"));
4325 }
4326 }
4327 exclusions
4328}
4329
4330fn validate_shared_incremental_target_boundary(
4331 spec: &WasmBuildSpec,
4332 inputs: &[(PathBuf, PathBuf)],
4333) -> Result<(), WasmBuildError> {
4334 let Some(shared_target) = shared_incremental_target(spec) else {
4335 return Ok(());
4336 };
4337 let shared_target =
4338 canonicalize_allow_missing(&shared_target).map_err(|source| WasmBuildError::Io {
4339 operation: "resolve shared incremental Cargo target boundary",
4340 path: shared_target.clone(),
4341 source,
4342 })?;
4343 let exact_entries = spec.target_dir.join(".ic-testkit/wasm-targets");
4344 let exact_entries =
4345 canonicalize_allow_missing(&exact_entries).map_err(|source| WasmBuildError::Io {
4346 operation: "resolve exact Wasm cache boundary",
4347 path: exact_entries,
4348 source,
4349 })?;
4350 if shared_target.starts_with(&exact_entries)
4353 || (exact_entries.starts_with(&shared_target)
4354 && !exact_entries.starts_with(shared_target.join(".ic-testkit")))
4355 {
4356 return Err(WasmBuildError::InvalidSpec {
4357 message: "shared incremental target must not overlap removable exact Wasm cache state"
4358 .to_owned(),
4359 });
4360 }
4361 let mut boundaries = super::inputs::boundaries::CargoInputBoundaries::default();
4362 let resolved_inputs = inputs
4363 .iter()
4364 .map(|(_, input)| {
4365 boundaries
4366 .input(
4367 input,
4368 "resolve Cargo input boundary",
4369 "inspect Cargo input boundary",
4370 )
4371 .cloned()
4372 .map_err(wasm_cache_fs_error)
4373 })
4374 .collect::<Result<Vec<_>, WasmBuildError>>()?;
4375 let has_safe_generated_root = std::iter::once(spec.target_dir.clone())
4376 .chain(std::iter::once(spec.workspace_root.join("target")))
4377 .chain(
4378 inputs
4379 .iter()
4380 .zip(&resolved_inputs)
4381 .filter(|(_, (_, is_directory))| *is_directory)
4382 .map(|((_, path), _)| path.join("target")),
4383 )
4384 .filter_map(|path| canonicalize_allow_missing(&path).ok())
4385 .filter(|root| {
4386 !resolved_inputs
4387 .iter()
4388 .any(|(input, _is_directory)| input.starts_with(root))
4389 })
4390 .fold(false, |safe, root| safe | shared_target.starts_with(root));
4392 if has_safe_generated_root {
4393 return Ok(());
4394 }
4395
4396 for (input, is_directory) in resolved_inputs {
4397 if shared_target == input
4398 || (is_directory && shared_target.starts_with(&input))
4399 || input.starts_with(&shared_target)
4400 {
4401 return Err(WasmBuildError::InvalidSpec {
4402 message: format!(
4403 "shared incremental target {} must not overlap exact Cargo inputs unless it is inside a generated target directory",
4404 shared_target.display()
4405 ),
4406 });
4407 }
4408 }
4409 Ok(())
4410}
4411
4412pub(super) fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
4413 let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
4414 return None;
4415 };
4416 Some(if target_dir.is_absolute() {
4417 target_dir.clone()
4418 } else {
4419 spec.workspace_root.join(target_dir)
4420 })
4421}
4422
4423fn shared_incremental_target_exists(
4424 spec: &WasmBuildSpec,
4425 operation: &'static str,
4426) -> Result<bool, WasmBuildError> {
4427 let target_dir =
4428 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
4429 message: "shared incremental target is not configured".to_owned(),
4430 })?;
4431 match fs::symlink_metadata(&target_dir) {
4432 Ok(metadata) if metadata.is_dir() => Ok(true),
4433 Ok(_) => Err(WasmBuildError::InvalidSpec {
4434 message: format!(
4435 "shared incremental Cargo target {} must be a directory",
4436 target_dir.display()
4437 ),
4438 }),
4439 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
4440 Err(source) => Err(WasmBuildError::Io {
4441 operation,
4442 path: target_dir,
4443 source,
4444 }),
4445 }
4446}
4447
4448fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
4449 command.env_clear().envs(&spec.environment);
4450}
4451
4452fn run_cargo_build(
4453 spec: &WasmBuildSpec,
4454 build_target_dir: &Path,
4455 targets: &[WasmArtifactTarget],
4456 progress: &mut ProgressReporter<'_>,
4457 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
4458) -> Result<Vec<PathBuf>, WasmBuildError> {
4459 let command = cargo_build_command(spec, build_target_dir)?;
4460 communicate_cargo_build(
4461 spec,
4462 build_target_dir,
4463 targets,
4464 command,
4465 progress,
4466 &mut spawn,
4467 )
4468}
4469
4470impl WasmBuildSpec {
4471 fn cargo_invocation(&self) -> CargoInvocation {
4472 CargoInvocation {
4473 cargo_program: self.cargo_program.clone(),
4474 working_directory: self.workspace_root.clone(),
4475 environment: self.environment.clone(),
4476 target: self.target.clone(),
4477 selection: self.cargo_selection.clone(),
4478 packages: self.packages.clone(),
4479 manifest_path: None,
4480 recipe: self.example.as_ref().map_or(CargoRecipe::Library, |name| {
4481 CargoRecipe::Example(name.clone())
4482 }),
4483 configurations: Vec::new(),
4484 render_diagnostics: false,
4485 keep_going: false,
4486 }
4487 }
4488}
4489
4490fn cargo_build_command(
4491 spec: &WasmBuildSpec,
4492 build_target_dir: &Path,
4493) -> Result<Command, WasmBuildError> {
4494 let absolute_target_dir =
4495 canonicalize_allow_missing(build_target_dir).map_err(|source| WasmBuildError::Io {
4496 operation: "resolve Cargo build target directory",
4497 path: build_target_dir.to_owned(),
4498 source,
4499 })?;
4500 let mut invocation = spec.cargo_invocation();
4501 invocation.environment.insert(
4502 "CARGO_TARGET_DIR".into(),
4503 absolute_target_dir.into_os_string(),
4504 );
4505 invocation.command().map_err(|source| WasmBuildError::Io {
4506 operation: "render Cargo build invocation",
4507 path: spec.workspace_root.clone(),
4508 source,
4509 })
4510}
4511
4512fn communicate_cargo_build(
4513 spec: &WasmBuildSpec,
4514 build_target_dir: &Path,
4515 targets: &[WasmArtifactTarget],
4516 mut command: Command,
4517 progress: &mut ProgressReporter<'_>,
4518 spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
4519) -> Result<Vec<PathBuf>, WasmBuildError> {
4520 command
4523 .stdin(Stdio::null())
4524 .stdout(Stdio::piped())
4525 .stderr(Stdio::piped());
4526 let started = Instant::now();
4527 let mut child = spawn(&mut command).map_err(|source| WasmBuildError::CommandSpawn {
4528 phase: WasmBuildPhase::CargoBuild,
4529 program: spec.cargo_program.clone(),
4530 source,
4531 })?;
4532 progress.emit(WasmBuildProgressEvent::CargoStarted {
4533 target_dir: build_target_dir.to_owned(),
4534 });
4535 let progress = RefCell::new(progress);
4536 let mut artifacts = vec![None; targets.len()];
4537 let mut collect = |message| collect_wasm_artifact(message, targets, &mut artifacts);
4538 let observed = communicate_cargo(
4539 &mut child,
4540 OutputLimits {
4541 stdout: OutputLimit::Truncate(CARGO_DIAGNOSTIC_BYTES),
4544 stderr: OutputLimit::Truncate(CARGO_DIAGNOSTIC_BYTES),
4545 timeout: None,
4546 },
4547 spec.cargo_message_bytes,
4548 || {
4549 progress
4550 .borrow_mut()
4551 .emit_heartbeat_if_due(WasmBuildPhase::CargoBuild, started.elapsed());
4552 false
4553 },
4554 |stream, bytes| {
4555 let mut progress = progress.borrow_mut();
4556 if progress.config.emit_cargo_output {
4557 progress.emit(WasmBuildProgressEvent::CargoOutput {
4558 stream,
4559 bytes: bytes.to_vec(),
4560 });
4561 }
4562 Ok(())
4563 },
4564 &mut collect,
4565 );
4566 let result = observed.execution;
4567 let status = match &result {
4568 Ok(evidence) => Some(evidence.status),
4569 Err(error) => error.evidence().and_then(|evidence| evidence.status),
4570 };
4571 if let Some(status) = status {
4572 progress
4573 .borrow_mut()
4574 .emit(WasmBuildProgressEvent::CargoFinished {
4575 success: status.success(),
4576 code: status.code(),
4577 elapsed: started.elapsed(),
4578 });
4579 }
4580 let evidence = result.map_err(|error| WasmBuildError::Io {
4581 operation: "communicate with cargo build",
4582 path: PathBuf::from(&spec.cargo_program),
4583 source: io::Error::other(error),
4585 })?;
4586 let status = evidence.status;
4587 if status.success() {
4588 observed.messages.map_err(|source| WasmBuildError::Io {
4589 operation: "decode Cargo build artifacts",
4590 path: PathBuf::from(&spec.cargo_program),
4591 source,
4592 })?;
4593 return artifacts
4594 .into_iter()
4595 .collect::<Option<Vec<_>>>()
4596 .ok_or_else(|| WasmBuildError::MissingArtifacts {
4597 paths: expected_artifacts(spec, build_target_dir, targets),
4598 });
4599 }
4600 Err(WasmBuildError::CommandFailed {
4601 phase: WasmBuildPhase::CargoBuild,
4602 status,
4603 stdout: cargo_diagnostic(&evidence.stdout, evidence.stdout_truncated),
4604 stderr: cargo_diagnostic(&evidence.stderr, evidence.stderr_truncated),
4605 })
4606}
4607
4608fn collect_wasm_artifact(
4609 message: schema::Message,
4610 targets: &[WasmArtifactTarget],
4611 artifacts: &mut [Option<PathBuf>],
4612) -> io::Result<()> {
4613 let schema::Message::CompilerArtifact(artifact) = message else {
4614 return Ok(());
4615 };
4616 let Some((_, selected)) = targets.iter().zip(artifacts).find(|(target, _)| {
4618 artifact_matches(&artifact, &target.package_id, &target.name, &target.kind)
4619 }) else {
4620 return Ok(());
4621 };
4622 if !artifact
4623 .target
4624 .crate_types
4625 .contains(&schema::CrateType::CDyLib)
4626 {
4627 return Err(io::Error::new(
4628 io::ErrorKind::InvalidData,
4629 "Cargo reported a non-cdylib Wasm target",
4630 ));
4631 }
4632 let mut matching = wasm_artifact_paths(&artifact);
4633 let path = matching
4634 .next()
4635 .filter(|path| path.is_absolute())
4636 .ok_or_else(|| {
4637 io::Error::new(
4638 io::ErrorKind::InvalidData,
4639 "Cargo omitted absolute Wasm artifact path",
4640 )
4641 })?;
4642 if matching.next().is_some() || selected.replace(path.to_path_buf()).is_some() {
4643 return Err(io::Error::new(
4644 io::ErrorKind::InvalidData,
4645 "Cargo reported ambiguous Wasm artifacts",
4646 ));
4647 }
4648 Ok(())
4649}
4650
4651fn cargo_diagnostic(bytes: &[u8], truncated: bool) -> String {
4652 let mut text = String::from_utf8_lossy(bytes).into_owned();
4653 if truncated {
4654 text.push_str("\n[diagnostic truncated after 1 MiB; enable Cargo output events for the complete stream]\n");
4655 }
4656 text
4657}
4658
4659fn ensure_command_success(
4660 phase: WasmBuildPhase,
4661 output: CompletedExecution,
4662) -> Result<CompletedExecution, WasmBuildError> {
4663 if output.status.success() {
4664 return Ok(output);
4665 }
4666 Err(WasmBuildError::CommandFailed {
4667 phase,
4668 status: output.status,
4669 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
4670 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
4671 })
4672}
4673
4674fn artifact_relative_path(spec: &WasmBuildSpec, target: &WasmArtifactTarget) -> PathBuf {
4675 let profile = Path::new(&spec.target).join(
4676 spec.cargo_selection
4677 .profile_target_dir()
4678 .expect("Cargo profile was validated before artifact discovery"),
4679 );
4680 let directory = if spec.example.is_some() {
4681 profile.join("examples")
4682 } else {
4683 profile
4684 };
4685 directory.join(format!("{}.wasm", target.name.replace('-', "_")))
4686}
4687
4688fn expected_artifacts(
4689 spec: &WasmBuildSpec,
4690 target_dir: &Path,
4691 targets: &[WasmArtifactTarget],
4692) -> Vec<PathBuf> {
4693 targets
4694 .iter()
4695 .map(|target| target_dir.join(artifact_relative_path(spec, target)))
4696 .collect()
4697}
4698
4699fn artifact_publication_paths(
4700 spec: &WasmBuildSpec,
4701 cache_entry: &Path,
4702 targets: &[WasmArtifactTarget],
4703) -> Vec<(PathBuf, PathBuf)> {
4704 targets
4705 .iter()
4706 .map(|target| {
4707 let relative = artifact_relative_path(spec, target);
4708 (cache_entry.join(&relative), spec.target_dir.join(relative))
4709 })
4710 .collect()
4711}
4712
4713#[derive(Debug, Eq, PartialEq)]
4714struct VerifiedWasmArtifact {
4715 cached_path: PathBuf,
4716 public_path: PathBuf,
4717 info: FileDigest,
4718}
4719
4720fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
4721 spec.target_dir
4722 .join(".ic-testkit/wasm-targets")
4723 .join(fingerprint.to_hex())
4724}
4725
4726fn validated_artifact_set(
4727 artifacts: Vec<(PathBuf, PathBuf)>,
4728 fingerprint: InputDigest,
4729) -> Option<Vec<VerifiedWasmArtifact>> {
4730 let header = artifact_stamp_header(fingerprint);
4731 let stamp_len =
4733 header.len() + ARTIFACT_STAMP_DIGEST_LABEL.len() + fingerprint.as_bytes().len() * 2 + 1;
4734 artifacts
4735 .into_iter()
4736 .map(|(artifact, public_path)| {
4737 let metadata = fs::metadata(&artifact).ok()?;
4738 if !metadata.is_file() || metadata.len() == 0 {
4739 return None;
4740 }
4741 let stamp =
4742 read_stamp_with_limit(&artifact_stamp_path(&artifact), stamp_len).ok()??;
4743 if stamp.len() != stamp_len || !stamp.starts_with(&header) {
4746 return None;
4747 }
4748 let info = digest_file("wasm-artifact-v1", &artifact).ok()?;
4749 (stamp == artifact_stamp_contents(fingerprint, info.digest)).then_some(
4750 VerifiedWasmArtifact {
4751 cached_path: artifact,
4752 public_path,
4753 info,
4754 },
4755 )
4756 })
4757 .collect()
4758}
4759
4760fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
4761 artifacts
4762 .iter()
4763 .filter(|path| {
4764 fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
4765 })
4766 .cloned()
4767 .collect()
4768}
4769
4770fn artifact_stamp_path(artifact: &Path) -> PathBuf {
4771 let mut name = artifact
4772 .file_name()
4773 .map_or_else(|| OsString::from("artifact"), OsString::from);
4774 name.push(".ic-testkit-build");
4775 artifact.with_file_name(name)
4776}
4777
4778fn artifact_stamp_header(fingerprint: InputDigest) -> String {
4779 format!("{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\n")
4780}
4781
4782fn artifact_stamp_contents(fingerprint: InputDigest, artifact_digest: InputDigest) -> String {
4783 format!(
4784 "{}{ARTIFACT_STAMP_DIGEST_LABEL}{artifact_digest}\n",
4785 artifact_stamp_header(fingerprint),
4786 )
4787}
4788
4789fn write_artifact_stamp(
4790 artifact: &Path,
4791 fingerprint: InputDigest,
4792 info: &FileDigest,
4793 preserve_matching: bool,
4794) -> Result<(), WasmBuildError> {
4795 let stamp_path = artifact_stamp_path(artifact);
4796 let stamp = artifact_stamp_contents(fingerprint, info.digest);
4797 if preserve_matching && destination_matches_bytes(&stamp_path, stamp.as_bytes()) {
4798 return Ok(());
4799 }
4800 ic_host_fs::durable::write_bytes(
4801 &stamp_path,
4802 ic_host_fs::durable::WriteOptions {
4803 mode: ic_host_fs::durable::PublicationMode::Replace,
4804 permissions: 0o666,
4805 },
4806 stamp.as_bytes(),
4807 )
4808 .map_err(|source| WasmBuildError::Io {
4809 operation: "publish Wasm build stamp",
4810 path: stamp_path,
4811 source: io::Error::other(source),
4812 })
4813}
4814
4815fn publish_artifact_stamps(
4816 artifacts: Vec<(PathBuf, PathBuf)>,
4817 fingerprint: InputDigest,
4818) -> Result<Vec<VerifiedWasmArtifact>, WasmBuildError> {
4819 artifacts
4820 .into_iter()
4821 .map(|(cached_path, public_path)| {
4822 let info = digest_file("wasm-artifact-v1", &cached_path).map_err(|source| {
4823 WasmBuildError::Io {
4824 operation: "hash built Wasm artifact",
4825 path: cached_path.clone(),
4826 source,
4827 }
4828 })?;
4829 write_artifact_stamp(&cached_path, fingerprint, &info, false)?;
4830 Ok(VerifiedWasmArtifact {
4831 cached_path,
4832 public_path,
4833 info,
4834 })
4835 })
4836 .collect()
4837}
4838
4839fn materialize_artifacts(
4840 artifacts: &[VerifiedWasmArtifact],
4841 fingerprint: InputDigest,
4842 preserve_matching: bool,
4843) -> Result<(), WasmBuildError> {
4844 for artifact in artifacts {
4845 materialize_verified_output(
4846 "wasm-artifact-v1",
4847 &artifact.cached_path,
4848 &artifact.public_path,
4849 preserve_matching.then_some(&artifact.info),
4850 )
4851 .map_err(|source| WasmBuildError::Io {
4852 operation: "publish Wasm artifact",
4853 path: artifact.public_path.clone(),
4854 source,
4855 })?;
4856 }
4857 for artifact in artifacts {
4859 write_artifact_stamp(
4860 &artifact.public_path,
4861 fingerprint,
4862 &artifact.info,
4863 preserve_matching,
4864 )?;
4865 }
4866 Ok(())
4867}
4868
4869fn copy_wasm_artifacts(
4870 source_artifacts: &[PathBuf],
4871 destinations: &[(PathBuf, PathBuf)],
4872) -> Result<(), WasmBuildError> {
4873 if source_artifacts.len() != destinations.len() {
4875 return Err(invalid_metadata(
4876 "Cargo artifact count does not match admitted outputs",
4877 ));
4878 }
4879 for (index, (cached, _)) in destinations.iter().enumerate() {
4880 let source = &source_artifacts[index];
4881 if source != cached {
4882 copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
4883 operation: "cache shared-incremental Wasm artifact",
4884 path: cached.clone(),
4885 source: source_error,
4886 })?;
4887 }
4888 }
4889 Ok(())
4890}
4891
4892fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
4893 fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
4894 operation,
4895 path: path.to_owned(),
4896 source,
4897 })
4898}
4899
4900fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
4901 if path.exists() {
4902 inputs.push((PathBuf::from(label), path));
4903 }
4904}
4905
4906fn invalid_metadata(message: &str) -> WasmBuildError {
4907 WasmBuildError::InvalidMetadata {
4908 message: message.to_owned(),
4909 }
4910}
4911
4912impl WasmBuildError {
4913 fn indicates_input_change(&self) -> bool {
4914 match self {
4915 Self::InputsChangedDuringAcquisition { .. } => true,
4916 Self::FailedBuildCleanup { build_error, .. } => build_error.indicates_input_change(),
4917 _ => false,
4918 }
4919 }
4920}
4921
4922impl std::fmt::Display for WasmBuildError {
4923 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4924 match self {
4925 Self::InvalidSpec { message } => {
4926 write!(formatter, "invalid Wasm build spec: {message}")
4927 }
4928 Self::Io {
4929 operation,
4930 path,
4931 source,
4932 } => write!(
4933 formatter,
4934 "failed to {operation} at {}: {source}",
4935 path.display()
4936 ),
4937 Self::CommandSpawn {
4938 phase,
4939 program,
4940 source,
4941 } => write!(
4942 formatter,
4943 "failed to launch {phase} using `{}`: {source}",
4944 program.to_string_lossy(),
4945 ),
4946 Self::CommandFailed {
4947 phase,
4948 status,
4949 stdout,
4950 stderr,
4951 } => write!(
4952 formatter,
4953 "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
4954 ),
4955 Self::InvalidMetadata { message } => {
4956 write!(formatter, "invalid Cargo metadata: {message}")
4957 }
4958 Self::InvalidCargoConfiguration { path, message } => write!(
4959 formatter,
4960 "invalid Cargo configuration at {}: {message}",
4961 path.display(),
4962 ),
4963 Self::MissingArtifacts { paths } => {
4964 formatter.write_str("cargo build succeeded without producing: ")?;
4965 for (index, path) in paths.iter().enumerate() {
4966 if index != 0 {
4967 formatter.write_str(", ")?;
4968 }
4969 write!(formatter, "{}", path.display())?;
4970 }
4971 Ok(())
4972 }
4973 Self::InputsChangedDuringAcquisition { before, after } => write!(
4974 formatter,
4975 "Wasm inputs changed during artifact acquisition: {before} -> {after}",
4976 ),
4977 Self::PreparedInputSnapshotInvalidated => formatter.write_str(
4978 "the prepared Wasm input snapshot was invalidated before artifact publication",
4979 ),
4980 Self::FailedBuildCleanup {
4981 build_error,
4982 path,
4983 source,
4984 } => write!(
4985 formatter,
4986 "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
4987 path.display(),
4988 ),
4989 }
4990 }
4991}
4992
4993impl std::error::Error for WasmBuildError {
4994 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
4995 match self {
4996 Self::Io { source, .. }
4997 | Self::CommandSpawn { source, .. }
4998 | Self::FailedBuildCleanup { source, .. } => Some(source),
4999 _ => None,
5000 }
5001 }
5002}
5003
5004#[cfg(test)]
5005mod tests;