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