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_process::{
10 child::{CleanupPolicy, OwnedChild},
11 tool::{
12 CompletedExecution, ExecutionError, ExecutionEvidence, ExecutionFailure,
13 ExecutionOperation, OutputLimit, OutputLimits, OutputStream, SuccessfulExit, ToolError,
14 capture_command, communicate_child,
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, directory_logical_size,
43 ensure_cache_directory_tag as ensure_cache_tag, is_sha256_directory, lock_cache_file,
44 lock_cache_file_with_wait_observer, perform_scheduled_cache_maintenance,
45 prune_direct_child_directories, record_cache_entry_use as record_entry_use,
46 record_cache_maintenance, 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_controlled_probe(command, limits, policy)
3599 } else {
3600 capture_command(command, limits)
3601 }
3602 .and_then(CompletedExecution::require_complete);
3603 let evidence = match result {
3604 Ok(evidence) => evidence,
3605 Err(ToolError::Execution(error))
3606 if matches!(
3607 error.failure,
3608 ExecutionFailure::Io {
3609 operation: ExecutionOperation::Spawn,
3610 ..
3611 }
3612 ) && error.cleanup.is_none() =>
3613 {
3614 let ExecutionFailure::Io { source, .. } = error.failure else {
3615 unreachable!()
3616 };
3617 return Err(WasmBuildError::CommandSpawn {
3618 phase,
3619 program: command.get_program().to_owned(),
3620 source,
3621 });
3622 }
3623 Err(error) => {
3624 return Err(WasmBuildError::Io {
3625 operation: match phase {
3626 WasmBuildPhase::CargoIdentity => "capture complete Cargo identity probe output",
3627 WasmBuildPhase::RustcIdentity => "capture complete rustc identity probe output",
3628 _ => "capture complete Cargo metadata probe output",
3629 },
3630 path: PathBuf::from(command.get_program()),
3631 source: io::Error::other(error),
3632 });
3633 }
3634 };
3635 Ok(evidence)
3636}
3637
3638fn capture_controlled_probe(
3639 command: &mut Command,
3640 mut limits: OutputLimits,
3641 policy: &mut CargoInputProbePolicy<'_>,
3642) -> Result<CompletedExecution, ToolError> {
3643 let failure = if (policy.cancelled)() {
3644 Some(ExecutionFailure::Cancelled)
3645 } else if policy
3646 .deadline
3647 .is_some_and(|deadline| deadline <= Instant::now())
3648 {
3649 Some(ExecutionFailure::TimedOut)
3650 } else {
3651 None
3652 };
3653 if let Some(failure) = failure {
3654 return Err(ToolError::Execution(Box::new(ExecutionError {
3655 failure,
3656 evidence: ExecutionEvidence::default(),
3657 cleanup: None,
3658 })));
3659 }
3660 command
3661 .stdin(Stdio::null())
3662 .stdout(Stdio::piped())
3663 .stderr(Stdio::piped());
3664 let mut child = OwnedChild::spawn(command, policy.cleanup).map_err(|source| {
3665 ToolError::Execution(Box::new(ExecutionError {
3666 failure: ExecutionFailure::Io {
3667 operation: ExecutionOperation::Spawn,
3668 source,
3669 },
3670 evidence: ExecutionEvidence::default(),
3671 cleanup: None,
3672 }))
3673 })?;
3674 limits.timeout = policy.deadline.map(|deadline| {
3676 deadline
3677 .saturating_duration_since(Instant::now())
3678 .max(Duration::from_nanos(1))
3679 });
3680 communicate_child(
3681 &mut child,
3682 None,
3683 limits,
3684 SuccessfulExit::Cleanup,
3685 &mut *policy.cancelled,
3686 )
3687}
3688
3689struct ParsedCargoMetadata<'a> {
3692 packages: HashMap<&'a str, &'a schema::Package>,
3693 workspace_packages: HashMap<&'a str, Option<&'a str>>,
3694 nodes: HashMap<&'a str, &'a schema::Node>,
3695 workspace_root: &'a Path,
3696}
3697
3698impl<'a> ParsedCargoMetadata<'a> {
3699 fn parse(metadata: &'a schema::Metadata) -> Result<Self, String> {
3700 let packages: HashMap<_, _> = metadata
3701 .packages
3702 .iter()
3703 .map(|package| (package.id.repr.as_str(), package))
3704 .collect();
3705 let workspace_members: HashSet<_> = metadata
3706 .workspace_members
3707 .iter()
3708 .map(|id| id.repr.as_str())
3709 .collect();
3710 let mut workspace_packages = HashMap::new();
3711 for package in packages
3712 .values()
3713 .filter(|package| workspace_members.contains(package.id.repr.as_str()))
3714 {
3715 workspace_packages
3716 .entry(package.name.as_str())
3717 .and_modify(|id| *id = None)
3718 .or_insert(Some(package.id.repr.as_str()));
3719 }
3720 let resolve = metadata
3721 .resolve
3722 .as_ref()
3723 .ok_or_else(|| "Cargo metadata has no resolved dependency nodes".to_owned())?;
3724 let nodes = resolve
3725 .nodes
3726 .iter()
3727 .map(|node| (node.id.repr.as_str(), node))
3728 .collect();
3729 Ok(Self {
3730 packages,
3731 workspace_packages,
3732 nodes,
3733 workspace_root: metadata.workspace_root.as_std_path(),
3734 })
3735 }
3736}
3737
3738struct LockedPackageIdentity {
3739 name: String,
3740 version: String,
3741 source: String,
3742 checksum: Option<String>,
3743}
3744
3745type LockedPackageIndex<'a> = HashMap<(&'a str, &'a str, &'a str), &'a LockedPackageIdentity>;
3746
3747fn index_locked_packages(packages: &[LockedPackageIdentity]) -> LockedPackageIndex<'_> {
3748 let mut index = HashMap::with_capacity(packages.len());
3749 for package in packages {
3750 index
3752 .entry((
3753 package.name.as_str(),
3754 package.version.as_str(),
3755 package.source.as_str(),
3756 ))
3757 .or_insert(package);
3758 }
3759 index
3760}
3761
3762fn resolve_local_inputs(
3763 spec: &WasmBuildSpec,
3764 metadata: &ParsedCargoMetadata<'_>,
3765) -> Result<(ResolvedLocalInputs, Vec<PathBuf>), WasmBuildError> {
3766 let mut selected_ids = selected_package_ids(spec, metadata)?;
3767 let mut artifact_names = BTreeSet::new();
3770 let wasm_targets = selected_ids
3771 .iter()
3772 .map(|id| {
3773 let package = metadata.packages[id];
3774 let mut matching = package.targets.iter().filter(|target| {
3775 target.crate_types.contains(&schema::CrateType::CDyLib)
3776 && spec.example.as_ref().map_or_else(
3777 || target.is_cdylib(),
3778 |example| target.name == *example && target.is_example(),
3779 )
3780 });
3781 let Some(target) = matching.next() else {
3782 return Err(format!(
3783 "Cargo package `{}` must declare the selected cdylib target",
3784 package.name
3785 ));
3786 };
3787 let mut components = Path::new(&target.name).components();
3788 if matching.next().is_some()
3789 || !matches!(
3790 (components.next(), components.next()),
3791 (Some(Component::Normal(_)), None)
3792 )
3793 {
3794 return Err(format!(
3795 "Cargo package `{}` has an ambiguous or unsafe cdylib target",
3796 package.name
3797 ));
3798 }
3799 let cache_name = target.name.replace('-', "_");
3800 if !artifact_names.insert(cache_name.clone()) {
3801 return Err(format!(
3802 "selected Cargo packages share the Wasm artifact name `{cache_name}`"
3803 ));
3804 }
3805 Ok(WasmArtifactTarget {
3806 package_id: package.id.repr.clone(),
3807 name: target.name.clone(),
3808 kind: if spec.example.is_some() {
3809 schema::TargetKind::Example
3810 } else {
3811 schema::TargetKind::CDyLib
3812 },
3813 })
3814 })
3815 .collect();
3816 let mut closure = BTreeSet::new();
3817 while let Some(id) = selected_ids.pop_front() {
3818 if !closure.insert(id) {
3819 continue;
3820 }
3821 if let Some(node) = metadata.nodes.get(id) {
3822 selected_ids.extend(
3823 node.deps
3824 .iter()
3825 .map(|dependency| dependency.pkg.repr.as_str()),
3826 );
3827 }
3828 }
3829
3830 let workspace_root = metadata.workspace_root;
3831 let workspace_projection = semantic_workspace_projection(metadata, &closure, workspace_root)?;
3832 let mut validation_inputs = workspace_configuration_inputs(spec, workspace_root)?;
3833 append_package_inputs(
3834 &mut validation_inputs,
3835 &metadata.packages,
3836 closure,
3837 workspace_root,
3838 )?;
3839 append_additional_inputs(&mut validation_inputs, spec, workspace_root);
3840 validate_shared_incremental_target_boundary(spec, &validation_inputs)?;
3841 let exclusions = source_exclusions(spec, &validation_inputs);
3842 Ok((
3843 ResolvedLocalInputs {
3844 validation_inputs,
3845 workspace_projection,
3846 wasm_targets,
3847 },
3848 exclusions,
3849 ))
3850}
3851
3852fn selected_package_ids<'a>(
3853 spec: &WasmBuildSpec,
3854 metadata: &ParsedCargoMetadata<'a>,
3855) -> Result<VecDeque<&'a str>, WasmBuildError> {
3856 let mut selected_ids = VecDeque::new();
3857 for requested in &spec.packages {
3858 match metadata.workspace_packages.get(requested.as_str()) {
3859 Some(Some(id)) => selected_ids.push_back(*id),
3860 None => {
3861 return Err(WasmBuildError::InvalidSpec {
3862 message: format!("Cargo workspace contains no package named `{requested}`"),
3863 });
3864 }
3865 Some(None) => {
3866 return Err(WasmBuildError::InvalidSpec {
3867 message: format!("Cargo workspace package name `{requested}` is ambiguous"),
3868 });
3869 }
3870 }
3871 }
3872 Ok(selected_ids)
3873}
3874
3875fn semantic_workspace_projection(
3876 metadata: &ParsedCargoMetadata<'_>,
3877 closure: &BTreeSet<&str>,
3878 workspace_root: &Path,
3879) -> Result<Option<InputDigest>, WasmBuildError> {
3880 let locked_packages = locked_package_identities(workspace_root)?;
3883 let locked_index = index_locked_packages(&locked_packages);
3884 let mut identities = HashMap::new();
3885 for &id in closure {
3886 let package = metadata
3887 .packages
3888 .get(id)
3889 .ok_or_else(|| invalid_metadata(&format!("resolved package `{id}` is missing")))?;
3890 let Some(identity) = semantic_package_identity(package, workspace_root, &locked_index)
3891 else {
3892 return Ok(None);
3893 };
3894 identities.insert(id, identity);
3895 }
3896
3897 let mut projected_packages = closure
3898 .iter()
3899 .map(|&id| {
3900 let package = metadata
3901 .packages
3902 .get(id)
3903 .expect("selected package closure was validated above");
3904 let identity = identities[id];
3905 let node = metadata.nodes.get(id).ok_or_else(|| {
3906 invalid_metadata(&format!("resolved package `{id}` has no dependency node"))
3907 })?;
3908 let projection = semantic_package_projection(package, node, &identities)?;
3909 Ok::<_, WasmBuildError>((identity, projection))
3910 })
3911 .collect::<Result<Vec<_>, _>>()?;
3912 projected_packages.sort_by_key(|(identity, _)| *identity);
3913
3914 let root_manifest = workspace_root.join("Cargo.toml");
3915 let root_contents =
3916 fs::read_to_string(&root_manifest).map_err(|source| WasmBuildError::Io {
3917 operation: "read workspace manifest for semantic projection",
3918 path: root_manifest.clone(),
3919 source,
3920 })?;
3921 let root = toml::from_str::<TomlValue>(&root_contents).map_err(|error| {
3922 invalid_metadata(&format!(
3923 "workspace manifest could not be projected as TOML: {error}"
3924 ))
3925 })?;
3926
3927 let mut hasher = InputHasher::new("wasm-semantic-workspace-projection-v1");
3928 for (identity, projection) in projected_packages {
3929 hasher.field("package-identity", identity.as_bytes());
3930 hasher.field("package-projection", projection.as_bytes());
3931 }
3932 hash_toml_setting(&mut hasher, "cargo-features", root.get("cargo-features"));
3933 hash_toml_setting(&mut hasher, "profile", root.get("profile"));
3934 let workspace = root.get("workspace").and_then(TomlValue::as_table);
3935 hash_toml_setting(
3936 &mut hasher,
3937 "workspace-resolver",
3938 workspace.and_then(|table| table.get("resolver")),
3939 );
3940 hash_toml_setting(
3941 &mut hasher,
3942 "workspace-lints",
3943 workspace.and_then(|table| table.get("lints")),
3944 );
3945 Ok(Some(hasher.finish()))
3946}
3947
3948fn locked_package_identities(
3949 workspace_root: &Path,
3950) -> Result<Vec<LockedPackageIdentity>, WasmBuildError> {
3951 use crate::cargo::lockfile::{external_package, optional_string, required_string};
3952 let lockfile = workspace_root.join("Cargo.lock");
3953 let contents = match fs::read_to_string(&lockfile) {
3954 Ok(contents) => contents,
3955 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
3956 Err(source) => {
3957 return Err(WasmBuildError::Io {
3958 operation: "read Cargo lockfile for semantic projection",
3959 path: lockfile,
3960 source,
3961 });
3962 }
3963 };
3964 let lock = toml::from_str::<TomlValue>(&contents).map_err(|error| {
3965 invalid_metadata(&format!(
3966 "Cargo lockfile could not be projected as TOML: {error}"
3967 ))
3968 })?;
3969 let Some(packages) = lock.get("package").and_then(TomlValue::as_array) else {
3970 return Ok(Vec::new());
3971 };
3972 packages
3973 .iter()
3974 .filter_map(|package| {
3975 let table = match external_package(package) {
3976 Ok(Some(table)) => table,
3977 Ok(None) => return None,
3978 Err(message) => {
3979 return Some(Err(invalid_metadata(&format!("Cargo lockfile {message}"))));
3980 }
3981 };
3982 let text = |field| {
3983 required_string(table, field)
3984 .map(str::to_owned)
3985 .map_err(|_| {
3986 invalid_metadata(&format!(
3987 "Cargo lockfile package `{field}` is missing or not a string"
3988 ))
3989 })
3990 };
3991 let source = required_string(table, "source").map(str::to_owned);
3992 Some(
3993 source
3994 .map_err(|_| invalid_metadata("Cargo lockfile package source is not a string"))
3995 .and_then(|source| {
3996 Ok(LockedPackageIdentity {
3997 name: text("name")?,
3998 version: text("version")?,
3999 source,
4000 checksum: optional_string(table, "checksum")
4001 .map_err(|_| {
4002 invalid_metadata(
4003 "Cargo lockfile package `checksum` is not a string",
4004 )
4005 })?
4006 .map(str::to_owned),
4007 })
4008 }),
4009 )
4010 })
4011 .collect()
4012}
4013
4014fn semantic_package_identity(
4015 package: &schema::Package,
4016 workspace_root: &Path,
4017 locked_packages: &LockedPackageIndex<'_>,
4018) -> Option<InputDigest> {
4019 let version = package.version.to_string();
4020 let mut hasher = InputHasher::new("wasm-semantic-package-identity-v1");
4021 hasher.field("name", package.name.as_bytes());
4022 hasher.field("version", version.as_bytes());
4023 if package.source.is_none() {
4024 let manifest = package.manifest_path.strip_prefix(workspace_root).ok()?;
4025 let package_root = package.manifest_path.parent()?;
4026 if package_root == workspace_root {
4027 return None;
4028 }
4029 hasher.field("local-manifest", &os_bytes(manifest.as_os_str()));
4030 } else {
4031 let metadata_source = &package.source.as_ref()?.repr;
4032 let locked = locked_packages.get(&(
4033 package.name.as_str(),
4034 version.as_str(),
4035 metadata_source.as_str(),
4036 ))?;
4037 match locked.source.as_str() {
4038 source if source.starts_with("registry+") && locked.checksum.is_some() => {}
4039 source if source.starts_with("git+") && source.contains('#') => {}
4040 _ => return None,
4041 }
4042 hasher.field("external-package-id", package.id.repr.as_bytes());
4043 hasher.field("external-source", locked.source.as_bytes());
4044 hasher.field(
4045 "external-checksum",
4046 locked.checksum.as_deref().unwrap_or_default().as_bytes(),
4047 );
4048 }
4049 Some(hasher.finish())
4050}
4051
4052fn semantic_package_projection(
4053 package: &schema::Package,
4054 node: &schema::Node,
4055 identities: &HashMap<&str, InputDigest>,
4056) -> Result<InputDigest, WasmBuildError> {
4057 let mut hasher = InputHasher::new("wasm-semantic-package-projection-v2");
4061 macro_rules! fields {
4064 ($($field:ident),* $(,)?) => {$(
4065 hasher.field("package-field-name", stringify!($field).as_bytes());
4066 hasher.field("package-field-value", &serde_json::to_vec(&package.$field)
4067 .expect("serializing Cargo fields into a Vec cannot fail"));
4068 )*};
4069 }
4070 fields!(
4071 authors,
4072 default_run,
4073 description,
4074 documentation,
4075 edition,
4076 homepage,
4077 license,
4078 license_file,
4079 links,
4080 metadata,
4081 name,
4082 readme,
4083 repository,
4084 rust_version,
4085 version
4086 );
4087
4088 let mut features = node
4089 .features
4090 .iter()
4091 .map(|feature| feature.as_str())
4092 .collect::<Vec<_>>();
4093 features.sort_unstable();
4094 for feature in features {
4095 hasher.field("enabled-feature", feature.as_bytes());
4096 }
4097
4098 let mut dependencies = node
4099 .deps
4100 .iter()
4101 .map(|dependency| {
4102 if dependency.dep_kinds.iter().any(|kind| {
4103 !matches!(
4104 kind.kind,
4105 schema::DependencyKind::Normal
4106 | schema::DependencyKind::Development
4107 | schema::DependencyKind::Build
4108 )
4109 }) {
4110 return Err(invalid_metadata("unsupported Cargo dependency kind"));
4111 }
4112 let name = dependency.name.as_str();
4113 let package_id = dependency.pkg.repr.as_str();
4114 let identity = identities.get(package_id).copied().ok_or_else(|| {
4115 invalid_metadata(&format!(
4116 "dependency `{package_id}` is outside the selected package closure"
4117 ))
4118 })?;
4119 let kinds = serde_json::to_vec(&dependency.dep_kinds)
4120 .expect("serializing Cargo dependency kinds into a Vec cannot fail");
4121 Ok::<_, WasmBuildError>((name, identity, kinds))
4122 })
4123 .collect::<Result<Vec<_>, _>>()?;
4124 dependencies.sort();
4125 for (name, identity, kinds) in dependencies {
4126 hasher.field("dependency-name", name.as_bytes());
4127 hasher.field("dependency-identity", identity.as_bytes());
4128 hasher.field("dependency-kinds", &kinds);
4129 }
4130 Ok(hasher.finish())
4131}
4132
4133fn hash_toml_setting(hasher: &mut InputHasher, label: &str, value: Option<&TomlValue>) {
4134 hasher.field("workspace-setting-name", label.as_bytes());
4135 match value {
4136 Some(value) => hasher.field("workspace-setting-value", value.to_string().as_bytes()),
4137 None => hasher.field("workspace-setting-missing", b""),
4138 }
4139}
4140
4141fn is_broad_workspace_input(label: &Path) -> bool {
4142 label == Path::new("workspace/Cargo.toml") || label == Path::new("workspace/Cargo.lock")
4143}
4144
4145fn digest_resolved_local_inputs(
4146 inputs: &ResolvedLocalInputs,
4147 exclusions: &[PathBuf],
4148 cache: &mut LabeledPathDigestCache,
4149 error_path: &Path,
4150 validation_operation: &'static str,
4151 semantic_operation: &'static str,
4152) -> Result<(InputDigest, InputDigest), WasmBuildError> {
4153 let validation_digest = digest_labeled_paths_composable(
4154 "wasm-source-inputs-v1",
4155 inputs
4156 .validation_inputs
4157 .iter()
4158 .map(|(label, path)| (label.as_path(), path.as_path())),
4159 exclusions,
4160 cache,
4161 )
4162 .map_err(|source| WasmBuildError::Io {
4163 operation: validation_operation,
4164 path: error_path.to_owned(),
4165 source,
4166 })?;
4167 let input_digest = semantic_input_digest(inputs, validation_digest, exclusions, cache)
4168 .map_err(|source| WasmBuildError::Io {
4169 operation: semantic_operation,
4170 path: error_path.to_owned(),
4171 source,
4172 })?;
4173 Ok((input_digest, validation_digest))
4174}
4175
4176fn semantic_input_digest(
4177 inputs: &ResolvedLocalInputs,
4178 validation_digest: InputDigest,
4179 exclusions: &[PathBuf],
4180 cache: &mut LabeledPathDigestCache,
4181) -> io::Result<InputDigest> {
4182 let Some(workspace) = inputs.workspace_projection else {
4183 return Ok(validation_digest);
4184 };
4185 let path_digest = digest_labeled_paths_composable(
4186 "wasm-source-inputs-v1",
4187 inputs
4188 .validation_inputs
4189 .iter()
4190 .filter(|(label, _)| !is_broad_workspace_input(label))
4191 .map(|(label, path)| (label.as_path(), path.as_path())),
4192 exclusions,
4193 cache,
4194 )?;
4195 let mut hasher = InputHasher::new("wasm-semantic-source-inputs-v1");
4196 hasher.field("path-input-digest", path_digest.as_bytes());
4197 hasher.field("workspace-projection", workspace.as_bytes());
4198 Ok(hasher.finish())
4199}
4200
4201fn workspace_configuration_inputs(
4202 spec: &WasmBuildSpec,
4203 workspace_root: &Path,
4204) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
4205 let mut inputs = Vec::new();
4206 add_if_present(
4207 &mut inputs,
4208 "workspace/Cargo.toml",
4209 workspace_root.join("Cargo.toml"),
4210 );
4211 add_if_present(
4212 &mut inputs,
4213 "workspace/Cargo.lock",
4214 workspace_root.join("Cargo.lock"),
4215 );
4216 add_if_present(
4217 &mut inputs,
4218 "workspace/rust-toolchain.toml",
4219 workspace_root.join("rust-toolchain.toml"),
4220 );
4221 add_if_present(
4222 &mut inputs,
4223 "workspace/rust-toolchain",
4224 workspace_root.join("rust-toolchain"),
4225 );
4226 append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
4227 Ok(inputs)
4228}
4229
4230fn append_cargo_configuration_inputs(
4231 inputs: &mut Vec<(PathBuf, PathBuf)>,
4232 spec: &WasmBuildSpec,
4233 workspace_root: &Path,
4234) -> Result<(), WasmBuildError> {
4235 let invocation_root =
4236 spec.workspace_root
4237 .canonicalize()
4238 .map_err(|source| WasmBuildError::Io {
4239 operation: "resolve Cargo invocation directory",
4240 path: spec.workspace_root.clone(),
4241 source,
4242 })?;
4243 let canonical_workspace =
4244 workspace_root
4245 .canonicalize()
4246 .map_err(|source| WasmBuildError::Io {
4247 operation: "resolve Cargo workspace directory",
4248 path: workspace_root.to_owned(),
4249 source,
4250 })?;
4251
4252 let cargo_home = command_environment_value(spec, "CARGO_HOME");
4253 let default_home = default_home_directory(spec);
4254 let directories = crate::cargo::environment::configuration_directories(
4255 &invocation_root,
4256 cargo_home.as_deref(),
4257 default_home.as_deref().map(Path::as_os_str),
4258 )
4259 .map_err(|source| WasmBuildError::Io {
4260 operation: "resolve Cargo configuration directories",
4261 path: invocation_root.clone(),
4262 source,
4263 })?;
4264 let roots: Vec<_> = directories
4265 .iter()
4266 .filter_map(|directory| crate::cargo::environment::preferred_configuration(directory))
4267 .collect();
4268 let observed =
4269 crate::cargo::environment::observe_configurations(&roots).map_err(|error| match error {
4270 crate::cargo::environment::ConfigurationError::Io {
4271 operation,
4272 path,
4273 source,
4274 } => WasmBuildError::Io {
4275 operation,
4276 path,
4277 source,
4278 },
4279 crate::cargo::environment::ConfigurationError::Invalid { path, message } => {
4280 WasmBuildError::InvalidCargoConfiguration { path, message }
4281 }
4282 })?;
4283 for config in observed {
4284 if !inputs.iter().any(|(_, path)| path == &config.path) {
4285 inputs.push((
4286 cargo_configuration_label(&config.location, &canonical_workspace),
4287 config.path,
4288 ));
4289 }
4290 }
4291 Ok(())
4292}
4293
4294#[cfg(windows)]
4295fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4296 command_environment_value(spec, "USERPROFILE")
4297 .or_else(|| command_environment_value(spec, "HOME"))
4298 .map(PathBuf::from)
4299}
4300
4301#[cfg(not(windows))]
4302fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4303 command_environment_value(spec, "HOME").map(PathBuf::from)
4304}
4305
4306fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
4307 spec.environment.get(OsStr::new(name)).cloned()
4308}
4309
4310fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
4311 if let Ok(relative) = config.strip_prefix(workspace_root) {
4312 return PathBuf::from("cargo-config/workspace").join(relative);
4313 }
4314 let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
4315 PathBuf::from("cargo-config/external").join(location.to_hex())
4316}
4317
4318fn append_package_inputs(
4319 inputs: &mut Vec<(PathBuf, PathBuf)>,
4320 packages: &HashMap<&str, &schema::Package>,
4321 closure: BTreeSet<&str>,
4322 workspace_root: &Path,
4323) -> Result<(), WasmBuildError> {
4324 for id in closure {
4325 let Some(package) = packages.get(id) else {
4326 return Err(invalid_metadata(&format!(
4327 "resolved package `{id}` is missing"
4328 )));
4329 };
4330 if package.source.is_some() {
4331 continue;
4332 }
4333 let root = package.manifest_path.parent().ok_or_else(|| {
4334 invalid_metadata(&format!(
4335 "package `{}` manifest has no parent",
4336 package.name
4337 ))
4338 })?;
4339 let relative_manifest = package
4340 .manifest_path
4341 .as_std_path()
4342 .strip_prefix(workspace_root)
4343 .unwrap_or(package.manifest_path.as_std_path());
4344 let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
4345 .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
4346 inputs.push((label, root.as_std_path().to_owned()));
4347 }
4348 Ok(())
4349}
4350
4351fn append_additional_inputs(
4352 inputs: &mut Vec<(PathBuf, PathBuf)>,
4353 spec: &WasmBuildSpec,
4354 workspace_root: &Path,
4355) {
4356 for additional in &spec.additional_inputs {
4357 let path = if additional.is_absolute() {
4358 additional.clone()
4359 } else {
4360 workspace_root.join(additional)
4361 };
4362 inputs.push((PathBuf::from("additional").join(additional), path));
4363 }
4364}
4365
4366fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
4367 let mut exclusions = vec![
4368 spec.target_dir.clone(),
4369 spec.workspace_root.join("target"),
4370 spec.workspace_root.join(".git"),
4371 ];
4372 if let Some(shared_target) = shared_incremental_target(spec) {
4373 exclusions.push(shared_target);
4374 }
4375 for (_, path) in inputs {
4376 if path.is_dir() {
4377 exclusions.push(path.join("target"));
4378 exclusions.push(path.join(".git"));
4379 }
4380 }
4381 exclusions
4382}
4383
4384fn validate_shared_incremental_target_boundary(
4385 spec: &WasmBuildSpec,
4386 inputs: &[(PathBuf, PathBuf)],
4387) -> Result<(), WasmBuildError> {
4388 let Some(shared_target) = shared_incremental_target(spec) else {
4389 return Ok(());
4390 };
4391 let shared_target =
4392 canonicalize_allow_missing(&shared_target).map_err(|source| WasmBuildError::Io {
4393 operation: "resolve shared incremental Cargo target boundary",
4394 path: shared_target.clone(),
4395 source,
4396 })?;
4397 let exact_entries = spec.target_dir.join(".ic-testkit/wasm-targets");
4398 let exact_entries =
4399 canonicalize_allow_missing(&exact_entries).map_err(|source| WasmBuildError::Io {
4400 operation: "resolve exact Wasm cache boundary",
4401 path: exact_entries,
4402 source,
4403 })?;
4404 if shared_target.starts_with(&exact_entries)
4407 || (exact_entries.starts_with(&shared_target)
4408 && !exact_entries.starts_with(shared_target.join(".ic-testkit")))
4409 {
4410 return Err(WasmBuildError::InvalidSpec {
4411 message: "shared incremental target must not overlap removable exact Wasm cache state"
4412 .to_owned(),
4413 });
4414 }
4415 let mut boundaries = super::inputs::boundaries::CargoInputBoundaries::default();
4416 let resolved_inputs = inputs
4417 .iter()
4418 .map(|(_, input)| {
4419 boundaries
4420 .input(
4421 input,
4422 "resolve Cargo input boundary",
4423 "inspect Cargo input boundary",
4424 )
4425 .cloned()
4426 .map_err(wasm_cache_fs_error)
4427 })
4428 .collect::<Result<Vec<_>, WasmBuildError>>()?;
4429 let has_safe_generated_root = std::iter::once(spec.target_dir.clone())
4430 .chain(std::iter::once(spec.workspace_root.join("target")))
4431 .chain(
4432 inputs
4433 .iter()
4434 .zip(&resolved_inputs)
4435 .filter(|(_, (_, is_directory))| *is_directory)
4436 .map(|((_, path), _)| path.join("target")),
4437 )
4438 .filter_map(|path| canonicalize_allow_missing(&path).ok())
4439 .filter(|root| {
4440 !resolved_inputs
4441 .iter()
4442 .any(|(input, _is_directory)| input.starts_with(root))
4443 })
4444 .fold(false, |safe, root| safe | shared_target.starts_with(root));
4446 if has_safe_generated_root {
4447 return Ok(());
4448 }
4449
4450 for (input, is_directory) in resolved_inputs {
4451 if shared_target == input
4452 || (is_directory && shared_target.starts_with(&input))
4453 || input.starts_with(&shared_target)
4454 {
4455 return Err(WasmBuildError::InvalidSpec {
4456 message: format!(
4457 "shared incremental target {} must not overlap exact Cargo inputs unless it is inside a generated target directory",
4458 shared_target.display()
4459 ),
4460 });
4461 }
4462 }
4463 Ok(())
4464}
4465
4466pub(super) fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
4467 let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
4468 return None;
4469 };
4470 Some(if target_dir.is_absolute() {
4471 target_dir.clone()
4472 } else {
4473 spec.workspace_root.join(target_dir)
4474 })
4475}
4476
4477fn shared_incremental_target_exists(
4478 spec: &WasmBuildSpec,
4479 operation: &'static str,
4480) -> Result<bool, WasmBuildError> {
4481 let target_dir =
4482 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
4483 message: "shared incremental target is not configured".to_owned(),
4484 })?;
4485 match fs::symlink_metadata(&target_dir) {
4486 Ok(metadata) if metadata.is_dir() => Ok(true),
4487 Ok(_) => Err(WasmBuildError::InvalidSpec {
4488 message: format!(
4489 "shared incremental Cargo target {} must be a directory",
4490 target_dir.display()
4491 ),
4492 }),
4493 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
4494 Err(source) => Err(WasmBuildError::Io {
4495 operation,
4496 path: target_dir,
4497 source,
4498 }),
4499 }
4500}
4501
4502fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
4503 command.env_clear().envs(&spec.environment);
4504}
4505
4506fn run_cargo_build(
4507 spec: &WasmBuildSpec,
4508 build_target_dir: &Path,
4509 targets: &[WasmArtifactTarget],
4510 progress: &mut ProgressReporter<'_>,
4511 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
4512) -> Result<Vec<PathBuf>, WasmBuildError> {
4513 let command = cargo_build_command(spec, build_target_dir)?;
4514 communicate_cargo_build(
4515 spec,
4516 build_target_dir,
4517 targets,
4518 command,
4519 progress,
4520 &mut spawn,
4521 )
4522}
4523
4524fn cargo_build_command(
4525 spec: &WasmBuildSpec,
4526 build_target_dir: &Path,
4527) -> Result<Command, WasmBuildError> {
4528 let absolute_target_dir =
4529 canonicalize_allow_missing(build_target_dir).map_err(|source| WasmBuildError::Io {
4530 operation: "resolve Cargo build target directory",
4531 path: build_target_dir.to_owned(),
4532 source,
4533 })?;
4534 let mut command = Command::new(&spec.cargo_program);
4535 apply_command_environment(&mut command, spec);
4536 command
4537 .current_dir(&spec.workspace_root)
4538 .env("CARGO_TARGET_DIR", absolute_target_dir)
4539 .args(["build", "--message-format=json"]);
4540 if let Some(example) = &spec.example {
4541 command.args(["--example", example]);
4542 } else {
4543 command.arg("--lib");
4544 }
4545 command
4546 .args(["--target", &spec.target])
4547 .args(spec.cargo_selection.build_arguments());
4548 for package in &spec.packages {
4549 command.args(["-p", package]);
4550 }
4551
4552 Ok(command)
4553}
4554
4555fn communicate_cargo_build(
4556 spec: &WasmBuildSpec,
4557 build_target_dir: &Path,
4558 targets: &[WasmArtifactTarget],
4559 mut command: Command,
4560 progress: &mut ProgressReporter<'_>,
4561 spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
4562) -> Result<Vec<PathBuf>, WasmBuildError> {
4563 command
4566 .stdin(Stdio::null())
4567 .stdout(Stdio::piped())
4568 .stderr(Stdio::piped());
4569 let started = Instant::now();
4570 let mut child = spawn(&mut command).map_err(|source| WasmBuildError::CommandSpawn {
4571 phase: WasmBuildPhase::CargoBuild,
4572 program: spec.cargo_program.clone(),
4573 source,
4574 })?;
4575 progress.emit(WasmBuildProgressEvent::CargoStarted {
4576 target_dir: build_target_dir.to_owned(),
4577 });
4578 let progress = RefCell::new(progress);
4579 let mut artifacts = vec![None; targets.len()];
4580 let mut collect = |message| collect_wasm_artifact(message, targets, &mut artifacts);
4581 let observed = communicate_cargo(
4582 &mut child,
4583 OutputLimits {
4584 stdout: OutputLimit::Truncate(CARGO_DIAGNOSTIC_BYTES),
4587 stderr: OutputLimit::Truncate(CARGO_DIAGNOSTIC_BYTES),
4588 timeout: None,
4589 },
4590 spec.cargo_message_bytes,
4591 || {
4592 progress
4593 .borrow_mut()
4594 .emit_heartbeat_if_due(WasmBuildPhase::CargoBuild, started.elapsed());
4595 false
4596 },
4597 |stream, bytes| {
4598 let mut progress = progress.borrow_mut();
4599 if progress.config.emit_cargo_output {
4600 progress.emit(WasmBuildProgressEvent::CargoOutput {
4601 stream,
4602 bytes: bytes.to_vec(),
4603 });
4604 }
4605 Ok(())
4606 },
4607 &mut collect,
4608 );
4609 let result = observed.execution;
4610 let status = match &result {
4611 Ok(evidence) => Some(evidence.status),
4612 Err(error) => error.evidence().and_then(|evidence| evidence.status),
4613 };
4614 if let Some(status) = status {
4615 progress
4616 .borrow_mut()
4617 .emit(WasmBuildProgressEvent::CargoFinished {
4618 success: status.success(),
4619 code: status.code(),
4620 elapsed: started.elapsed(),
4621 });
4622 }
4623 let evidence = result.map_err(|error| WasmBuildError::Io {
4624 operation: "communicate with cargo build",
4625 path: PathBuf::from(&spec.cargo_program),
4626 source: io::Error::other(error),
4628 })?;
4629 let status = evidence.status;
4630 if status.success() {
4631 observed.messages.map_err(|source| WasmBuildError::Io {
4632 operation: "decode Cargo build artifacts",
4633 path: PathBuf::from(&spec.cargo_program),
4634 source,
4635 })?;
4636 return artifacts
4637 .into_iter()
4638 .collect::<Option<Vec<_>>>()
4639 .ok_or_else(|| WasmBuildError::MissingArtifacts {
4640 paths: expected_artifacts(spec, build_target_dir, targets),
4641 });
4642 }
4643 Err(WasmBuildError::CommandFailed {
4644 phase: WasmBuildPhase::CargoBuild,
4645 status,
4646 stdout: cargo_diagnostic(&evidence.stdout, evidence.stdout_truncated),
4647 stderr: cargo_diagnostic(&evidence.stderr, evidence.stderr_truncated),
4648 })
4649}
4650
4651fn collect_wasm_artifact(
4652 message: schema::Message,
4653 targets: &[WasmArtifactTarget],
4654 artifacts: &mut [Option<PathBuf>],
4655) -> io::Result<()> {
4656 let schema::Message::CompilerArtifact(artifact) = message else {
4657 return Ok(());
4658 };
4659 let Some((_, selected)) = targets.iter().zip(artifacts).find(|(target, _)| {
4661 artifact_matches(&artifact, &target.package_id, &target.name, &target.kind)
4662 }) else {
4663 return Ok(());
4664 };
4665 if !artifact
4666 .target
4667 .crate_types
4668 .contains(&schema::CrateType::CDyLib)
4669 {
4670 return Err(io::Error::new(
4671 io::ErrorKind::InvalidData,
4672 "Cargo reported a non-cdylib Wasm target",
4673 ));
4674 }
4675 let mut matching = wasm_artifact_paths(&artifact);
4676 let path = matching
4677 .next()
4678 .filter(|path| path.is_absolute())
4679 .ok_or_else(|| {
4680 io::Error::new(
4681 io::ErrorKind::InvalidData,
4682 "Cargo omitted absolute Wasm artifact path",
4683 )
4684 })?;
4685 if matching.next().is_some() || selected.replace(path.to_path_buf()).is_some() {
4686 return Err(io::Error::new(
4687 io::ErrorKind::InvalidData,
4688 "Cargo reported ambiguous Wasm artifacts",
4689 ));
4690 }
4691 Ok(())
4692}
4693
4694fn cargo_diagnostic(bytes: &[u8], truncated: bool) -> String {
4695 let mut text = String::from_utf8_lossy(bytes).into_owned();
4696 if truncated {
4697 text.push_str("\n[diagnostic truncated after 1 MiB; enable Cargo output events for the complete stream]\n");
4698 }
4699 text
4700}
4701
4702fn ensure_command_success(
4703 phase: WasmBuildPhase,
4704 output: CompletedExecution,
4705) -> Result<CompletedExecution, WasmBuildError> {
4706 if output.status.success() {
4707 return Ok(output);
4708 }
4709 Err(WasmBuildError::CommandFailed {
4710 phase,
4711 status: output.status,
4712 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
4713 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
4714 })
4715}
4716
4717fn artifact_relative_path(spec: &WasmBuildSpec, target: &WasmArtifactTarget) -> PathBuf {
4718 let profile = Path::new(&spec.target).join(
4719 spec.cargo_selection
4720 .profile_target_dir()
4721 .expect("Cargo profile was validated before artifact discovery"),
4722 );
4723 let directory = if spec.example.is_some() {
4724 profile.join("examples")
4725 } else {
4726 profile
4727 };
4728 directory.join(format!("{}.wasm", target.name.replace('-', "_")))
4729}
4730
4731fn expected_artifacts(
4732 spec: &WasmBuildSpec,
4733 target_dir: &Path,
4734 targets: &[WasmArtifactTarget],
4735) -> Vec<PathBuf> {
4736 targets
4737 .iter()
4738 .map(|target| target_dir.join(artifact_relative_path(spec, target)))
4739 .collect()
4740}
4741
4742fn artifact_publication_paths(
4743 spec: &WasmBuildSpec,
4744 cache_entry: &Path,
4745 targets: &[WasmArtifactTarget],
4746) -> Vec<(PathBuf, PathBuf)> {
4747 targets
4748 .iter()
4749 .map(|target| {
4750 let relative = artifact_relative_path(spec, target);
4751 (cache_entry.join(&relative), spec.target_dir.join(relative))
4752 })
4753 .collect()
4754}
4755
4756#[derive(Debug, Eq, PartialEq)]
4757struct VerifiedWasmArtifact {
4758 cached_path: PathBuf,
4759 public_path: PathBuf,
4760 info: FileDigest,
4761}
4762
4763fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
4764 spec.target_dir
4765 .join(".ic-testkit/wasm-targets")
4766 .join(fingerprint.to_hex())
4767}
4768
4769fn validated_artifact_set(
4770 artifacts: Vec<(PathBuf, PathBuf)>,
4771 fingerprint: InputDigest,
4772) -> Option<Vec<VerifiedWasmArtifact>> {
4773 let header = artifact_stamp_header(fingerprint);
4774 let stamp_len =
4776 header.len() + ARTIFACT_STAMP_DIGEST_LABEL.len() + fingerprint.as_bytes().len() * 2 + 1;
4777 artifacts
4778 .into_iter()
4779 .map(|(artifact, public_path)| {
4780 let metadata = fs::metadata(&artifact).ok()?;
4781 if !metadata.is_file() || metadata.len() == 0 {
4782 return None;
4783 }
4784 let stamp =
4785 read_stamp_with_limit(&artifact_stamp_path(&artifact), stamp_len).ok()??;
4786 if stamp.len() != stamp_len || !stamp.starts_with(&header) {
4789 return None;
4790 }
4791 let info = digest_file("wasm-artifact-v1", &artifact).ok()?;
4792 (stamp == artifact_stamp_contents(fingerprint, info.digest)).then_some(
4793 VerifiedWasmArtifact {
4794 cached_path: artifact,
4795 public_path,
4796 info,
4797 },
4798 )
4799 })
4800 .collect()
4801}
4802
4803fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
4804 artifacts
4805 .iter()
4806 .filter(|path| {
4807 fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
4808 })
4809 .cloned()
4810 .collect()
4811}
4812
4813fn artifact_stamp_path(artifact: &Path) -> PathBuf {
4814 let mut name = artifact
4815 .file_name()
4816 .map_or_else(|| OsString::from("artifact"), OsString::from);
4817 name.push(".ic-testkit-build");
4818 artifact.with_file_name(name)
4819}
4820
4821fn artifact_stamp_header(fingerprint: InputDigest) -> String {
4822 format!("{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\n")
4823}
4824
4825fn artifact_stamp_contents(fingerprint: InputDigest, artifact_digest: InputDigest) -> String {
4826 format!(
4827 "{}{ARTIFACT_STAMP_DIGEST_LABEL}{artifact_digest}\n",
4828 artifact_stamp_header(fingerprint),
4829 )
4830}
4831
4832fn write_artifact_stamp(
4833 artifact: &Path,
4834 fingerprint: InputDigest,
4835 info: &FileDigest,
4836 preserve_matching: bool,
4837) -> Result<(), WasmBuildError> {
4838 let stamp_path = artifact_stamp_path(artifact);
4839 let stamp = artifact_stamp_contents(fingerprint, info.digest);
4840 if preserve_matching && destination_matches_bytes(&stamp_path, stamp.as_bytes()) {
4841 return Ok(());
4842 }
4843 ic_host_fs::durable::write_bytes(
4844 &stamp_path,
4845 ic_host_fs::durable::WriteOptions {
4846 mode: ic_host_fs::durable::PublicationMode::Replace,
4847 permissions: 0o666,
4848 },
4849 stamp.as_bytes(),
4850 )
4851 .map_err(|source| WasmBuildError::Io {
4852 operation: "publish Wasm build stamp",
4853 path: stamp_path,
4854 source: io::Error::other(source),
4855 })
4856}
4857
4858fn publish_artifact_stamps(
4859 artifacts: Vec<(PathBuf, PathBuf)>,
4860 fingerprint: InputDigest,
4861) -> Result<Vec<VerifiedWasmArtifact>, WasmBuildError> {
4862 artifacts
4863 .into_iter()
4864 .map(|(cached_path, public_path)| {
4865 let info = digest_file("wasm-artifact-v1", &cached_path).map_err(|source| {
4866 WasmBuildError::Io {
4867 operation: "hash built Wasm artifact",
4868 path: cached_path.clone(),
4869 source,
4870 }
4871 })?;
4872 write_artifact_stamp(&cached_path, fingerprint, &info, false)?;
4873 Ok(VerifiedWasmArtifact {
4874 cached_path,
4875 public_path,
4876 info,
4877 })
4878 })
4879 .collect()
4880}
4881
4882fn materialize_artifacts(
4883 artifacts: &[VerifiedWasmArtifact],
4884 fingerprint: InputDigest,
4885 preserve_matching: bool,
4886) -> Result<(), WasmBuildError> {
4887 for artifact in artifacts {
4888 materialize_verified_output(
4889 "wasm-artifact-v1",
4890 &artifact.cached_path,
4891 &artifact.public_path,
4892 preserve_matching.then_some(&artifact.info),
4893 )
4894 .map_err(|source| WasmBuildError::Io {
4895 operation: "publish Wasm artifact",
4896 path: artifact.public_path.clone(),
4897 source,
4898 })?;
4899 }
4900 for artifact in artifacts {
4902 write_artifact_stamp(
4903 &artifact.public_path,
4904 fingerprint,
4905 &artifact.info,
4906 preserve_matching,
4907 )?;
4908 }
4909 Ok(())
4910}
4911
4912fn copy_wasm_artifacts(
4913 source_artifacts: &[PathBuf],
4914 destinations: &[(PathBuf, PathBuf)],
4915) -> Result<(), WasmBuildError> {
4916 if source_artifacts.len() != destinations.len() {
4918 return Err(invalid_metadata(
4919 "Cargo artifact count does not match admitted outputs",
4920 ));
4921 }
4922 for (index, (cached, _)) in destinations.iter().enumerate() {
4923 let source = &source_artifacts[index];
4924 if source != cached {
4925 copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
4926 operation: "cache shared-incremental Wasm artifact",
4927 path: cached.clone(),
4928 source: source_error,
4929 })?;
4930 }
4931 }
4932 Ok(())
4933}
4934
4935fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
4936 fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
4937 operation,
4938 path: path.to_owned(),
4939 source,
4940 })
4941}
4942
4943fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
4944 if path.exists() {
4945 inputs.push((PathBuf::from(label), path));
4946 }
4947}
4948
4949fn invalid_metadata(message: &str) -> WasmBuildError {
4950 WasmBuildError::InvalidMetadata {
4951 message: message.to_owned(),
4952 }
4953}
4954
4955impl WasmBuildError {
4956 fn indicates_input_change(&self) -> bool {
4957 match self {
4958 Self::InputsChangedDuringAcquisition { .. } => true,
4959 Self::FailedBuildCleanup { build_error, .. } => build_error.indicates_input_change(),
4960 _ => false,
4961 }
4962 }
4963}
4964
4965impl std::fmt::Display for WasmBuildError {
4966 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4967 match self {
4968 Self::InvalidSpec { message } => {
4969 write!(formatter, "invalid Wasm build spec: {message}")
4970 }
4971 Self::Io {
4972 operation,
4973 path,
4974 source,
4975 } => write!(
4976 formatter,
4977 "failed to {operation} at {}: {source}",
4978 path.display()
4979 ),
4980 Self::CommandSpawn {
4981 phase,
4982 program,
4983 source,
4984 } => write!(
4985 formatter,
4986 "failed to launch {phase} using `{}`: {source}",
4987 program.to_string_lossy(),
4988 ),
4989 Self::CommandFailed {
4990 phase,
4991 status,
4992 stdout,
4993 stderr,
4994 } => write!(
4995 formatter,
4996 "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
4997 ),
4998 Self::InvalidMetadata { message } => {
4999 write!(formatter, "invalid Cargo metadata: {message}")
5000 }
5001 Self::InvalidCargoConfiguration { path, message } => write!(
5002 formatter,
5003 "invalid Cargo configuration at {}: {message}",
5004 path.display(),
5005 ),
5006 Self::MissingArtifacts { paths } => {
5007 formatter.write_str("cargo build succeeded without producing: ")?;
5008 for (index, path) in paths.iter().enumerate() {
5009 if index != 0 {
5010 formatter.write_str(", ")?;
5011 }
5012 write!(formatter, "{}", path.display())?;
5013 }
5014 Ok(())
5015 }
5016 Self::InputsChangedDuringAcquisition { before, after } => write!(
5017 formatter,
5018 "Wasm inputs changed during artifact acquisition: {before} -> {after}",
5019 ),
5020 Self::PreparedInputSnapshotInvalidated => formatter.write_str(
5021 "the prepared Wasm input snapshot was invalidated before artifact publication",
5022 ),
5023 Self::FailedBuildCleanup {
5024 build_error,
5025 path,
5026 source,
5027 } => write!(
5028 formatter,
5029 "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
5030 path.display(),
5031 ),
5032 }
5033 }
5034}
5035
5036impl std::error::Error for WasmBuildError {
5037 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
5038 match self {
5039 Self::Io { source, .. }
5040 | Self::CommandSpawn { source, .. }
5041 | Self::FailedBuildCleanup { source, .. } => Some(source),
5042 _ => None,
5043 }
5044 }
5045}
5046
5047#[cfg(test)]
5048mod tests;