1use super::phase::WasmBuildPhase;
2use crate::cargo::{
3 execution::{
4 CargoInvocation, CargoRecipe, CargoTool, TOOL_IDENTITY_BYTES, communicate_cargo,
5 wasm_artifact_paths,
6 },
7 messages::artifact_matches,
8 observation::{metadata_arguments, schema},
9 selection::{CargoSelection, is_normal_component},
10};
11use ic_host_fs::owned_directory::{OwnedDirectory, remove_path_if_present};
12use ic_host_fs::read::directory_logical_size;
13use ic_host_process::{
14 child::{CleanupPolicy, OwnedChild},
15 tool::{
16 CompletedExecution, ExecutionFailure, ExecutionOperation, OutputLimit, OutputLimits,
17 OutputStream, ToolError, capture_command, capture_group_command_with_cancellation,
18 },
19};
20use std::{
21 cell::RefCell,
22 collections::{BTreeMap, HashMap},
23 ffi::{OsStr, OsString},
24 fs::{self, File},
25 io,
26 path::{Path, PathBuf},
27 process::{Command, ExitStatus, Stdio},
28 sync::{
29 Arc, RwLock,
30 atomic::{AtomicUsize, Ordering},
31 mpsc::{self, RecvTimeoutError},
32 },
33 thread,
34 time::{Duration, Instant, SystemTime},
35};
36
37mod acquisition;
38use acquisition::prepare_exact_cache_entry;
39mod resolution;
40mod source_inputs;
41use resolution::{ParsedCargoMetadata, digest_resolved_local_inputs, resolve_local_inputs};
42
43use super::{
44 cache_fs::{
45 ArtifactCacheMaintenance, ArtifactCachePruneReport, CacheFsError, CacheRetentionLimits,
46 RetainedCacheEntry, cache_entry_last_used, cache_maintenance_due,
47 canonicalize_allow_missing, ensure_cache_directory_tag as ensure_cache_tag,
48 is_sha256_directory, lock_cache_file, lock_cache_file_with_wait_observer,
49 perform_scheduled_cache_maintenance, prune_direct_child_directories,
50 record_cache_entry_use as record_entry_use, record_cache_maintenance,
51 remove_unretained_entry,
52 },
53 digest::{
54 FileDigest, InputDigest, InputHasher, LabeledPathDigestCache, copy_file_atomic,
55 destination_matches_bytes, digest_file, digest_labeled_paths_composable,
56 materialize_verified_output, os_bytes, read_stamp_with_limit,
57 },
58};
59
60const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
61const BUILD_FINGERPRINT_DOMAIN: &str = "ic-build-explicit-environment-fingerprint";
63const ARTIFACT_STAMP_DIGEST_LABEL: &str = "artifact-sha256:";
64const CARGO_DIAGNOSTIC_BYTES: usize = 1024 * 1024;
66const CARGO_METADATA_BYTES: usize = 16 * 1024 * 1024;
67
68const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
69
70#[derive(Clone, Eq, PartialEq)]
78pub struct WasmBuildSpec {
79 configuration_limits: crate::cargo::environment::ConfigurationLimits,
80 workspace_root: PathBuf,
81 target_dir: PathBuf,
82 packages: Vec<String>,
83 example: Option<String>,
84 cargo_selection: CargoSelection,
85 environment: BTreeMap<OsString, OsString>,
86 additional_inputs: Vec<PathBuf>,
87 target: String,
88 cargo_program: OsString,
89 rustc_program: OsString,
90 cargo_message_bytes: usize,
91 cache_mode: WasmBuildCacheMode,
92 pruning: Option<(CacheRetentionLimits, Duration)>,
93 shared_incremental_maintenance_config: Option<SharedIncrementalTargetMaintenanceConfig>,
94}
95
96impl std::fmt::Debug for WasmBuildSpec {
97 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 formatter
99 .debug_struct("WasmBuildSpec")
100 .field("configuration_limits", &self.configuration_limits)
101 .field("workspace_root", &self.workspace_root)
102 .field("target_dir", &self.target_dir)
103 .field("packages", &self.packages)
104 .field("example", &self.example)
105 .field("cargo_selection", &self.cargo_selection)
106 .field(
107 "environment_names",
108 &self.environment.keys().collect::<Vec<_>>(),
109 )
110 .field("additional_inputs", &self.additional_inputs)
111 .field("target", &self.target)
112 .field("cargo_program", &self.cargo_program)
113 .field("rustc_program", &self.rustc_program)
114 .field("cargo_message_bytes", &self.cargo_message_bytes)
115 .field("cache_mode", &self.cache_mode)
116 .field("pruning", &self.pruning)
117 .field(
118 "shared_incremental_maintenance_config",
119 &self.shared_incremental_maintenance_config,
120 )
121 .finish()
122 }
123}
124
125#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
127pub enum SharedIncrementalTargetMaintenanceFailureMode {
128 #[default]
130 Strict,
131 BestEffort,
133}
134
135#[derive(Clone, Copy, Debug, Eq, PartialEq)]
137pub struct SharedIncrementalTargetMaintenanceConfig {
138 policy: CacheRetentionLimits,
139 minimum_interval: Duration,
140 failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
141}
142
143#[non_exhaustive]
145#[derive(Clone, Debug, Eq, PartialEq)]
146pub enum WasmBuildCacheMode {
147 Isolated,
149 SharedIncremental {
151 target_dir: PathBuf,
153 },
154}
155
156#[derive(Clone, Debug, Eq, PartialEq)]
158pub enum WasmBuildOutcome {
159 Built(WasmBuildRecord),
161 Reused(WasmBuildRecord),
163}
164
165#[derive(Clone, Debug, Eq, PartialEq)]
167pub struct WasmBuildRecord {
168 facts: CargoBuildFacts,
169 fingerprint: InputDigest,
170 recipe_identity: InputDigest,
171 input_digest: InputDigest,
172 retention: RetainedCacheEntry,
173 artifacts: Arc<[PathBuf]>,
174 timings: WasmBuildTimings,
175 maintenance: Option<ArtifactCacheMaintenance>,
176 shared_incremental_maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
177}
178
179#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
181pub struct WasmBuildTimings {
182 lock_wait: Duration,
183 shared_incremental_lock_wait: Option<Duration>,
184 input_resolution: WasmInputResolutionTimings,
185 cargo_build: Option<Duration>,
186 cache_maintenance: Option<Duration>,
187 total: Duration,
188}
189
190#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
192pub struct WasmInputResolutionTimings {
193 tool_identity: Duration,
194 cargo_metadata: Duration,
195 input_discovery: Duration,
196 content_hashing: Duration,
197 total: Duration,
198}
199
200#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
202pub struct WasmBuildFailureTimings {
203 exact_cache_coordination: Duration,
204 shared_target_coordination: Option<Duration>,
205 input_resolution: WasmInputResolutionTimings,
206 shared_target_maintenance: Option<Duration>,
207 cargo_build: Option<Duration>,
208 artifact_publication: Option<Duration>,
209 exact_cache_maintenance: Option<Duration>,
210 cleanup: Option<Duration>,
211 total: Duration,
212}
213
214#[derive(Clone, Copy, Debug, Eq, PartialEq)]
216pub struct WasmBuildFailureDetails {
217 phase: WasmBuildPhase,
218 timings: WasmBuildFailureTimings,
219}
220
221#[derive(Clone, Debug, Eq, PartialEq)]
223pub struct CargoBuildInput {
224 label: PathBuf,
225 path: PathBuf,
226}
227
228#[derive(Clone, Eq, PartialEq)]
236pub struct CargoBuildFacts {
237 pub selection: CargoSelection,
239 pub target: String,
241 pub environment: BTreeMap<OsString, OsString>,
244 pub cargo_identity: Vec<u8>,
246 pub rustc_identity: Vec<u8>,
248}
249
250impl std::fmt::Debug for CargoBuildFacts {
251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252 f.debug_struct("CargoBuildFacts")
253 .field("selection", &self.selection)
254 .field("target", &self.target)
255 .field("environment_entries", &self.environment.len())
256 .field("cargo_identity_bytes", &self.cargo_identity.len())
257 .field("rustc_identity_bytes", &self.rustc_identity.len())
258 .finish()
259 }
260}
261
262#[derive(Clone, Debug, Eq, PartialEq)]
269pub struct ResolvedCargoBuildInputs {
270 facts: CargoBuildFacts,
271 recipe_identity: InputDigest,
272 fingerprint: InputDigest,
273 input_digest: InputDigest,
274 validation_digest: InputDigest,
275 inputs: Arc<[CargoBuildInput]>,
276 base_exclusions: Arc<[PathBuf]>,
278 declared_package_files: Arc<[PathBuf]>,
279 exclusions: Arc<[PathBuf]>,
280 wasm_outputs: Result<ResolvedWasmOutputs, String>,
281 timings: WasmInputResolutionTimings,
282}
283
284pub(super) struct WasmBuildBatchInputResolver<'a, 'session> {
285 specs: Vec<&'a WasmBuildSpec>,
286 groups: Vec<BatchResolutionGroup>,
287 group_by_index: Vec<usize>,
288 resolved: Vec<Option<Result<ResolvedCargoBuildInputs, (WasmBuildPhase, WasmBuildError)>>>,
289 reuse: Option<&'session WasmBuildInputSnapshotState>,
290 metrics: WasmBuildBatchInputMetrics,
291}
292
293pub(super) struct WasmBuildInputSnapshotState {
294 snapshots: Vec<(WasmBuildSpec, ResolvedCargoBuildInputs)>,
295 preparation_metrics: WasmBuildBatchInputMetrics,
296 preparation_timings: WasmInputResolutionTimings,
297 reader_reuses: AtomicUsize,
298 invalidation: Arc<RwLock<bool>>,
299}
300
301struct BatchResolutionGroup {
302 indexes: Vec<usize>,
303}
304
305#[derive(Clone, Debug, Eq, PartialEq)]
306struct WasmArtifactTarget {
307 package_id: String,
308 name: String,
309 kind: schema::TargetKind,
310}
311
312#[derive(Clone, Debug, Eq, PartialEq)]
313struct ResolvedWasmOutputs {
314 targets: Arc<[WasmArtifactTarget]>,
315 artifact_paths: Arc<[PathBuf]>,
316}
317
318struct ResolvedLocalInputs {
319 validation_inputs: Vec<(PathBuf, PathBuf)>,
320 base_exclusions: Vec<PathBuf>,
321 declared_package_files: Vec<PathBuf>,
322 workspace_projection: Option<InputDigest>,
323 configuration_identity: InputDigest,
324 wasm_targets: Result<Vec<WasmArtifactTarget>, String>,
325}
326
327#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
328pub(super) struct WasmBuildBatchInputMetrics {
329 pub(super) runs: usize,
330 pub(super) reuses: usize,
331 pub(super) prepared_reuses: usize,
332}
333
334#[derive(Eq, Hash, PartialEq)]
335struct BatchResolutionKey<'a> {
336 workspace_root: &'a Path,
337 cargo_program: &'a OsStr,
338 rustc_program: &'a OsStr,
339 metadata_arguments: Vec<OsString>,
340 environment: &'a BTreeMap<OsString, OsString>,
341}
342
343#[derive(Clone, Debug, Eq, PartialEq)]
345pub struct SharedIncrementalTargetInspection {
346 target_dir: PathBuf,
347 logical_size_bytes: u64,
348 last_used: SystemTime,
349 lock_wait: Duration,
350}
351
352#[derive(Clone, Debug, Eq, PartialEq)]
354pub struct SharedIncrementalTargetMaintenance {
355 target_dir: PathBuf,
356 logical_size_bytes_before: u64,
357 logical_size_bytes_after: u64,
358 last_used_before: SystemTime,
359 cleared: bool,
360 lock_wait: Duration,
361 maintenance: Duration,
362}
363
364#[non_exhaustive]
366#[derive(Clone, Debug, Eq, PartialEq)]
367pub enum SharedIncrementalTargetMaintenanceOutcome {
368 Missing {
370 target_dir: PathBuf,
372 },
373 Skipped {
375 target_dir: PathBuf,
377 lock_wait: Duration,
379 schedule_check: Duration,
381 },
382 Performed {
384 maintenance: SharedIncrementalTargetMaintenance,
386 schedule_check: Duration,
388 },
389 Failed {
391 target_dir: PathBuf,
393 lock_wait: Duration,
395 message: String,
397 },
398}
399
400#[derive(Clone, Copy, Debug, Eq, PartialEq)]
402pub struct WasmBuildProgressConfig {
403 heartbeat_interval: Option<Duration>,
404 emit_cargo_output: bool,
405}
406
407#[derive(Clone, Copy, Debug, Eq, PartialEq)]
409pub enum WasmBuildProgressOutcome {
410 Built,
412 Reused,
414}
415
416#[non_exhaustive]
418#[derive(Clone, Debug, Eq, PartialEq)]
419pub enum WasmBuildProgressEvent {
420 Started,
422 InputsResolved {
424 fingerprint: InputDigest,
426 input_digest: InputDigest,
428 elapsed: Duration,
430 },
431 CacheMiss {
433 fingerprint: InputDigest,
435 },
436 CacheHit {
438 fingerprint: InputDigest,
440 },
441 SharedTargetLockStarted {
443 target_dir: PathBuf,
445 },
446 SharedTargetLockAcquired {
448 target_dir: PathBuf,
450 wait: Duration,
452 },
453 SharedTargetMaintenanceStarted {
455 target_dir: PathBuf,
457 },
458 SharedTargetMaintenanceFinished {
460 outcome: SharedIncrementalTargetMaintenanceOutcome,
462 },
463 CargoStarted {
465 target_dir: PathBuf,
467 },
468 CargoOutput {
470 stream: OutputStream,
472 bytes: Vec<u8>,
474 },
475 Heartbeat {
477 phase: WasmBuildPhase,
479 elapsed: Duration,
481 },
482 CargoFinished {
484 success: bool,
486 code: Option<i32>,
488 elapsed: Duration,
490 },
491 Finished {
493 outcome: WasmBuildProgressOutcome,
495 fingerprint: InputDigest,
497 elapsed: Duration,
499 },
500}
501
502impl Default for WasmBuildProgressConfig {
503 fn default() -> Self {
504 Self {
505 heartbeat_interval: Some(Duration::from_secs(10)),
506 emit_cargo_output: true,
507 }
508 }
509}
510
511impl WasmBuildProgressConfig {
512 #[must_use]
514 pub fn new() -> Self {
515 Self::default()
516 }
517
518 #[must_use]
522 pub const fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
523 self.heartbeat_interval = Some(interval);
524 self
525 }
526
527 #[must_use]
529 pub const fn without_heartbeats(mut self) -> Self {
530 self.heartbeat_interval = None;
531 self
532 }
533
534 #[must_use]
540 pub const fn with_cargo_output(mut self, emit: bool) -> Self {
541 self.emit_cargo_output = emit;
542 self
543 }
544
545 #[must_use]
547 pub const fn heartbeat_interval(self) -> Option<Duration> {
548 self.heartbeat_interval
549 }
550
551 #[must_use]
553 pub const fn emits_cargo_output(self) -> bool {
554 self.emit_cargo_output
555 }
556}
557
558struct ProgressReporter<'a> {
559 config: WasmBuildProgressConfig,
560 observer: Option<&'a mut dyn FnMut(WasmBuildProgressEvent)>,
561 last_event: Instant,
562 failure_phase: Option<WasmBuildPhase>,
563 failure_timings: WasmBuildFailureTimings,
564}
565
566impl<'a> ProgressReporter<'a> {
567 fn silent() -> Self {
568 Self {
569 config: WasmBuildProgressConfig {
570 heartbeat_interval: None,
571 emit_cargo_output: false,
572 },
573 observer: None,
574 last_event: Instant::now(),
575 failure_phase: None,
576 failure_timings: WasmBuildFailureTimings::default(),
577 }
578 }
579
580 fn new(
581 observation: Option<(
582 WasmBuildProgressConfig,
583 &'a mut dyn FnMut(WasmBuildProgressEvent),
584 )>,
585 ) -> Result<Self, WasmBuildError> {
586 let Some((config, observer)) = observation else {
587 return Ok(Self::silent());
588 };
589 if config.heartbeat_interval == Some(Duration::ZERO) {
590 return Err(WasmBuildError::InvalidSpec {
591 message: "Wasm build progress heartbeat interval must be greater than zero"
592 .to_owned(),
593 });
594 }
595 Ok(ProgressReporter {
596 config,
597 observer: Some(observer),
598 last_event: Instant::now(),
599 failure_phase: None,
600 failure_timings: WasmBuildFailureTimings::default(),
601 })
602 }
603
604 fn emit(&mut self, event: WasmBuildProgressEvent) {
605 if let Some(observer) = &mut self.observer {
606 observer(event);
607 self.last_event = Instant::now();
608 }
609 }
610
611 const fn is_observed(&self) -> bool {
612 self.observer.is_some()
613 }
614
615 fn heartbeat_due_in(&self) -> Option<Duration> {
616 self.config
617 .heartbeat_interval
618 .map(|interval| interval.saturating_sub(self.last_event.elapsed()))
619 }
620
621 fn emit_heartbeat(&mut self, phase: WasmBuildPhase, elapsed: Duration) {
622 self.emit(WasmBuildProgressEvent::Heartbeat { phase, elapsed });
623 }
624
625 fn emit_heartbeat_if_due(&mut self, phase: WasmBuildPhase, elapsed: Duration) {
626 if self.heartbeat_due_in() == Some(Duration::ZERO) {
627 self.emit_heartbeat(phase, elapsed);
628 }
629 }
630
631 fn run_phase<T, F>(&mut self, phase: WasmBuildPhase, operation: F) -> T
632 where
633 T: Send,
634 F: FnOnce() -> T + Send,
635 {
636 let started = Instant::now();
637 self.begin_phase(phase);
638 let result = if !self.is_observed() || self.config.heartbeat_interval.is_none() {
639 operation()
640 } else {
641 thread::scope(|scope| {
642 let (finished, completion) = mpsc::sync_channel(0);
643 let worker = scope.spawn(move || {
644 let result = operation();
645 let _ = finished.send(());
646 result
647 });
648 loop {
649 let wait = self
650 .heartbeat_due_in()
651 .expect("observed phase must have a heartbeat interval");
652 match completion.recv_timeout(wait) {
653 Ok(()) | Err(RecvTimeoutError::Disconnected) => {
654 return worker
655 .join()
656 .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
657 }
658 Err(RecvTimeoutError::Timeout) => {
659 self.emit_heartbeat(phase, started.elapsed());
660 }
661 }
662 }
663 })
664 };
665 self.record_phase(phase, started.elapsed());
666 result
667 }
668
669 const fn begin_phase(&mut self, phase: WasmBuildPhase) {
670 self.failure_phase = Some(phase);
671 }
672
673 fn record_phase(&mut self, phase: WasmBuildPhase, elapsed: Duration) {
674 self.failure_phase = Some(phase);
675 let timings = &mut self.failure_timings;
676 let timing = match phase {
679 WasmBuildPhase::Specification => return,
680 WasmBuildPhase::ExactCacheCoordination => &mut timings.exact_cache_coordination,
681 WasmBuildPhase::CargoIdentity | WasmBuildPhase::RustcIdentity => {
682 &mut timings.input_resolution.tool_identity
683 }
684 WasmBuildPhase::CargoMetadata => &mut timings.input_resolution.cargo_metadata,
685 WasmBuildPhase::InputDiscovery => &mut timings.input_resolution.input_discovery,
686 WasmBuildPhase::ContentHashing => &mut timings.input_resolution.content_hashing,
687 WasmBuildPhase::SharedTargetCoordination => timings
688 .shared_target_coordination
689 .get_or_insert(Duration::ZERO),
690 WasmBuildPhase::SharedTargetMaintenance => timings
691 .shared_target_maintenance
692 .get_or_insert(Duration::ZERO),
693 WasmBuildPhase::CargoBuild => timings.cargo_build.get_or_insert(Duration::ZERO),
694 WasmBuildPhase::ArtifactPublication => {
695 timings.artifact_publication.get_or_insert(Duration::ZERO)
696 }
697 WasmBuildPhase::ExactCacheMaintenance => timings
698 .exact_cache_maintenance
699 .get_or_insert(Duration::ZERO),
700 WasmBuildPhase::Cleanup => timings.cleanup.get_or_insert(Duration::ZERO),
701 };
702 *timing = timing.saturating_add(elapsed);
703 if matches!(
704 phase,
705 WasmBuildPhase::CargoIdentity
706 | WasmBuildPhase::RustcIdentity
707 | WasmBuildPhase::CargoMetadata
708 | WasmBuildPhase::InputDiscovery
709 | WasmBuildPhase::ContentHashing
710 ) {
711 timings.input_resolution.total = timings.input_resolution.total.saturating_add(elapsed);
712 }
713 }
714
715 fn failure_details(&self, error: &WasmBuildError, total: Duration) -> WasmBuildFailureDetails {
716 let phase = self
717 .failure_phase
718 .unwrap_or_else(|| classify_unobserved_failure(error));
719 let mut timings = self.failure_timings;
720 timings.total = total;
721 WasmBuildFailureDetails { phase, timings }
722 }
723}
724
725fn classify_unobserved_failure(error: &WasmBuildError) -> WasmBuildPhase {
726 match error {
727 WasmBuildError::InvalidSpec { .. } => WasmBuildPhase::Specification,
728 WasmBuildError::CommandSpawn { phase, .. }
729 | WasmBuildError::CommandFailed { phase, .. } => *phase,
730 WasmBuildError::InvalidMetadata { .. } => WasmBuildPhase::CargoMetadata,
731 WasmBuildError::InvalidCargoConfiguration { .. } => WasmBuildPhase::InputDiscovery,
732 WasmBuildError::MissingArtifacts { .. }
733 | WasmBuildError::PreparedInputSnapshotInvalidated => WasmBuildPhase::ArtifactPublication,
734 WasmBuildError::InputsChangedDuringAcquisition { .. } => WasmBuildPhase::ContentHashing,
735 WasmBuildError::FailedBuildCleanup { .. } => WasmBuildPhase::Cleanup,
736 WasmBuildError::Io { source, .. }
737 if source
738 .get_ref()
739 .is_some_and(<dyn std::error::Error + Send + Sync>::is::<ToolError>) =>
740 {
741 WasmBuildPhase::CargoBuild
742 }
743 WasmBuildError::Io { .. } => WasmBuildPhase::ExactCacheCoordination,
744 }
745}
746
747#[non_exhaustive]
749#[derive(Debug)]
750pub enum WasmBuildError {
751 InvalidSpec {
753 message: String,
755 },
756 Io {
758 operation: &'static str,
760 path: PathBuf,
762 source: io::Error,
764 },
765 CommandSpawn {
767 phase: WasmBuildPhase,
769 program: OsString,
771 source: io::Error,
773 },
774 CommandFailed {
778 phase: WasmBuildPhase,
780 status: ExitStatus,
782 stdout: String,
784 stderr: String,
786 },
787 InvalidMetadata {
789 message: String,
791 },
792 InvalidCargoConfiguration {
794 path: PathBuf,
796 message: String,
798 },
799 MissingArtifacts {
801 paths: Vec<PathBuf>,
803 },
804 InputsChangedDuringAcquisition {
806 before: InputDigest,
808 after: InputDigest,
810 },
811 PreparedInputSnapshotInvalidated,
813 FailedBuildCleanup {
815 build_error: Box<Self>,
817 path: PathBuf,
819 source: io::Error,
821 },
822}
823
824impl WasmBuildSpec {
825 #[must_use]
831 pub const fn with_cargo_message_bytes(mut self, maximum: usize) -> Self {
832 self.cargo_message_bytes = maximum;
833 self
834 }
835
836 #[must_use]
863 pub fn new<I, K, V>(
864 workspace_root: &Path,
865 target_dir: &Path,
866 packages: &[&str],
867 environment: I,
868 configuration_limits: crate::cargo::environment::ConfigurationLimits,
869 ) -> Self
870 where
871 I: IntoIterator<Item = (K, V)>,
872 K: Into<OsString>,
873 V: Into<OsString>,
874 {
875 let environment: BTreeMap<OsString, OsString> = environment
876 .into_iter()
877 .map(|(key, value)| (key.into(), value.into()))
878 .collect();
879 let mut packages = packages
880 .iter()
881 .map(|package| (*package).to_owned())
882 .collect::<Vec<_>>();
883 packages.sort();
884 packages.dedup();
885 Self {
886 configuration_limits,
887 workspace_root: workspace_root.to_owned(),
888 target_dir: target_dir.to_owned(),
889 packages,
890 example: None,
891 cargo_selection: CargoSelection::default(),
892 cargo_program: environment
893 .get(OsStr::new("CARGO"))
894 .cloned()
895 .unwrap_or_else(|| "cargo".into()),
896 rustc_program: environment
897 .get(OsStr::new("RUSTC"))
898 .cloned()
899 .unwrap_or_else(|| "rustc".into()),
900 environment,
901 additional_inputs: Vec::new(),
902 target: DEFAULT_TARGET.to_owned(),
903 cargo_message_bytes: CARGO_METADATA_BYTES,
904 cache_mode: WasmBuildCacheMode::SharedIncremental {
905 target_dir: PathBuf::from("target/ic-testkit-incremental"),
906 },
907 pruning: None,
908 shared_incremental_maintenance_config: None,
909 }
910 }
911
912 #[must_use]
918 pub fn with_example(mut self, name: impl Into<String>) -> Self {
919 self.example = Some(name.into());
920 self
921 }
922
923 #[must_use]
925 pub fn example(&self) -> Option<&str> {
926 self.example.as_deref()
927 }
928
929 pub fn cargo_build_command(&self) -> Result<Command, WasmBuildError> {
941 validate_spec(self)?;
942 cargo_build_command(self, &self.target_dir)
943 }
944
945 #[must_use]
950 pub fn with_cargo_selection(mut self, selection: CargoSelection) -> Self {
951 self.cargo_selection = selection;
952 self
953 }
954
955 #[must_use]
961 pub fn with_environment<I, K, V>(mut self, environment: I) -> Self
962 where
963 I: IntoIterator<Item = (K, V)>,
964 K: Into<OsString>,
965 V: Into<OsString>,
966 {
967 self.environment = environment
968 .into_iter()
969 .map(|(key, value)| (key.into(), value.into()))
970 .collect();
971 self
972 }
973
974 #[must_use]
976 pub const fn environment(&self) -> &BTreeMap<OsString, OsString> {
977 &self.environment
978 }
979
980 #[must_use]
985 pub fn with_additional_inputs<I, P>(mut self, paths: I) -> Self
986 where
987 I: IntoIterator<Item = P>,
988 P: Into<PathBuf>,
989 {
990 self.additional_inputs
991 .extend(paths.into_iter().map(Into::into));
992 self
993 }
994
995 #[must_use]
997 pub fn with_target(mut self, target: &str) -> Self {
998 target.clone_into(&mut self.target);
999 self
1000 }
1001
1002 #[must_use]
1004 pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
1005 self.cargo_program = program.into();
1006 self
1007 }
1008
1009 #[must_use]
1017 pub fn with_rustc_identity_program(mut self, program: impl Into<OsString>) -> Self {
1018 self.rustc_program = program.into();
1019 self
1020 }
1021
1022 #[must_use]
1028 pub fn with_shared_incremental_target(mut self, target_dir: impl Into<PathBuf>) -> Self {
1029 self.cache_mode = WasmBuildCacheMode::SharedIncremental {
1030 target_dir: target_dir.into(),
1031 };
1032 self
1033 }
1034
1035 #[must_use]
1040 pub fn with_isolated_builds(mut self) -> Self {
1041 self.cache_mode = WasmBuildCacheMode::Isolated;
1042 self
1043 }
1044
1045 #[must_use]
1056 pub const fn with_shared_incremental_target_maintenance_at_most_every(
1057 mut self,
1058 policy: CacheRetentionLimits,
1059 minimum_interval: Duration,
1060 ) -> Self {
1061 self.shared_incremental_maintenance_config = Some(
1062 SharedIncrementalTargetMaintenanceConfig::new(policy, minimum_interval),
1063 );
1064 self
1065 }
1066
1067 #[must_use]
1073 pub const fn with_shared_incremental_target_maintenance(
1074 mut self,
1075 config: SharedIncrementalTargetMaintenanceConfig,
1076 ) -> Self {
1077 self.shared_incremental_maintenance_config = Some(config);
1078 self
1079 }
1080
1081 #[must_use]
1087 pub const fn with_prune_policy(mut self, policy: CacheRetentionLimits) -> Self {
1088 self.pruning = Some((policy, Duration::ZERO));
1089 self
1090 }
1091
1092 #[must_use]
1099 pub const fn with_prune_policy_at_most_every(
1100 mut self,
1101 policy: CacheRetentionLimits,
1102 minimum_interval: Duration,
1103 ) -> Self {
1104 self.pruning = Some((policy, minimum_interval));
1105 self
1106 }
1107
1108 #[must_use]
1110 pub fn workspace_root(&self) -> &Path {
1111 &self.workspace_root
1112 }
1113
1114 #[must_use]
1116 pub fn target_dir(&self) -> &Path {
1117 &self.target_dir
1118 }
1119
1120 #[must_use]
1122 pub fn packages(&self) -> &[String] {
1123 &self.packages
1124 }
1125
1126 #[must_use]
1128 pub const fn cache_mode(&self) -> &WasmBuildCacheMode {
1129 &self.cache_mode
1130 }
1131
1132 #[must_use]
1134 pub const fn prune_policy(&self) -> Option<CacheRetentionLimits> {
1135 match self.pruning {
1136 Some((policy, _)) => Some(policy),
1137 None => None,
1138 }
1139 }
1140
1141 #[must_use]
1146 pub const fn prune_interval(&self) -> Option<Duration> {
1147 match self.pruning {
1148 Some((_, interval)) if !interval.is_zero() => Some(interval),
1149 _ => None,
1150 }
1151 }
1152
1153 #[must_use]
1155 pub const fn shared_incremental_target_maintenance(
1156 &self,
1157 ) -> Option<SharedIncrementalTargetMaintenanceConfig> {
1158 self.shared_incremental_maintenance_config
1159 }
1160}
1161
1162impl WasmBuildOutcome {
1163 #[must_use]
1165 pub const fn record(&self) -> &WasmBuildRecord {
1166 match self {
1167 Self::Built(record) | Self::Reused(record) => record,
1168 }
1169 }
1170
1171 #[must_use]
1173 pub const fn is_reused(&self) -> bool {
1174 matches!(self, Self::Reused(_))
1175 }
1176}
1177
1178impl WasmBuildRecord {
1179 #[must_use]
1185 pub const fn facts(&self) -> &CargoBuildFacts {
1186 &self.facts
1187 }
1188
1189 #[must_use]
1191 pub const fn fingerprint(&self) -> InputDigest {
1192 self.fingerprint
1193 }
1194
1195 #[must_use]
1198 pub const fn recipe_identity(&self) -> InputDigest {
1199 self.recipe_identity
1200 }
1201
1202 #[must_use]
1204 pub const fn input_digest(&self) -> InputDigest {
1205 self.input_digest
1206 }
1207
1208 #[must_use]
1215 pub fn exact_cache_path(&self) -> &Path {
1216 self.retention.path()
1217 }
1218
1219 #[must_use]
1224 pub fn artifacts(&self) -> &[PathBuf] {
1225 &self.artifacts
1226 }
1227
1228 #[must_use]
1230 pub const fn timings(&self) -> WasmBuildTimings {
1231 self.timings
1232 }
1233
1234 #[must_use]
1236 pub const fn maintenance(&self) -> Option<&ArtifactCacheMaintenance> {
1237 self.maintenance.as_ref()
1238 }
1239
1240 #[must_use]
1242 pub const fn shared_incremental_maintenance(
1243 &self,
1244 ) -> Option<&SharedIncrementalTargetMaintenanceOutcome> {
1245 self.shared_incremental_maintenance.as_ref()
1246 }
1247}
1248
1249impl WasmBuildTimings {
1250 #[must_use]
1252 pub const fn lock_wait(self) -> Duration {
1253 self.lock_wait
1254 }
1255
1256 #[must_use]
1258 pub const fn shared_incremental_lock_wait(self) -> Option<Duration> {
1259 self.shared_incremental_lock_wait
1260 }
1261
1262 #[must_use]
1264 pub const fn input_resolution(self) -> WasmInputResolutionTimings {
1265 self.input_resolution
1266 }
1267
1268 #[must_use]
1270 pub const fn cargo_build(self) -> Option<Duration> {
1271 self.cargo_build
1272 }
1273
1274 #[must_use]
1276 pub const fn cache_maintenance(self) -> Option<Duration> {
1277 self.cache_maintenance
1278 }
1279
1280 #[must_use]
1282 pub const fn total(self) -> Duration {
1283 self.total
1284 }
1285
1286 pub(super) fn saturating_add(self, other: Self) -> Self {
1287 let mut input_resolution = self.input_resolution;
1288 input_resolution.include(other.input_resolution);
1289 Self {
1290 lock_wait: self.lock_wait.saturating_add(other.lock_wait),
1291 shared_incremental_lock_wait: [
1292 self.shared_incremental_lock_wait,
1293 other.shared_incremental_lock_wait,
1294 ]
1295 .into_iter()
1296 .flatten()
1297 .reduce(Duration::saturating_add),
1298 input_resolution,
1299 cargo_build: [self.cargo_build, other.cargo_build]
1300 .into_iter()
1301 .flatten()
1302 .reduce(Duration::saturating_add),
1303 cache_maintenance: [self.cache_maintenance, other.cache_maintenance]
1304 .into_iter()
1305 .flatten()
1306 .reduce(Duration::saturating_add),
1307 total: self.total.saturating_add(other.total),
1308 }
1309 }
1310}
1311
1312impl WasmInputResolutionTimings {
1313 #[must_use]
1315 pub const fn tool_identity(self) -> Duration {
1316 self.tool_identity
1317 }
1318
1319 #[must_use]
1321 pub const fn cargo_metadata(self) -> Duration {
1322 self.cargo_metadata
1323 }
1324
1325 #[must_use]
1327 pub const fn input_discovery(self) -> Duration {
1328 self.input_discovery
1329 }
1330
1331 #[must_use]
1333 pub const fn content_hashing(self) -> Duration {
1334 self.content_hashing
1335 }
1336
1337 #[must_use]
1339 pub const fn total(self) -> Duration {
1340 self.total
1341 }
1342
1343 const fn include(&mut self, other: Self) {
1344 self.tool_identity = self.tool_identity.saturating_add(other.tool_identity);
1345 self.cargo_metadata = self.cargo_metadata.saturating_add(other.cargo_metadata);
1346 self.input_discovery = self.input_discovery.saturating_add(other.input_discovery);
1347 self.content_hashing = self.content_hashing.saturating_add(other.content_hashing);
1348 self.total = self.total.saturating_add(other.total);
1349 }
1350}
1351
1352impl WasmBuildFailureDetails {
1353 pub(super) fn specification(total: Duration) -> Self {
1354 Self {
1355 phase: WasmBuildPhase::Specification,
1356 timings: WasmBuildFailureTimings {
1357 total,
1358 ..WasmBuildFailureTimings::default()
1359 },
1360 }
1361 }
1362
1363 #[must_use]
1365 pub const fn phase(self) -> WasmBuildPhase {
1366 self.phase
1367 }
1368
1369 #[must_use]
1371 pub const fn timings(self) -> WasmBuildFailureTimings {
1372 self.timings
1373 }
1374}
1375
1376impl WasmBuildFailureTimings {
1377 #[must_use]
1379 pub const fn exact_cache_coordination(self) -> Duration {
1380 self.exact_cache_coordination
1381 }
1382
1383 #[must_use]
1385 pub const fn shared_target_coordination(self) -> Option<Duration> {
1386 self.shared_target_coordination
1387 }
1388
1389 #[must_use]
1391 pub const fn input_resolution(self) -> WasmInputResolutionTimings {
1392 self.input_resolution
1393 }
1394
1395 #[must_use]
1397 pub const fn shared_target_maintenance(self) -> Option<Duration> {
1398 self.shared_target_maintenance
1399 }
1400
1401 #[must_use]
1403 pub const fn cargo_build(self) -> Option<Duration> {
1404 self.cargo_build
1405 }
1406
1407 #[must_use]
1409 pub const fn artifact_publication(self) -> Option<Duration> {
1410 self.artifact_publication
1411 }
1412
1413 #[must_use]
1415 pub const fn exact_cache_maintenance(self) -> Option<Duration> {
1416 self.exact_cache_maintenance
1417 }
1418
1419 #[must_use]
1421 pub const fn cleanup(self) -> Option<Duration> {
1422 self.cleanup
1423 }
1424
1425 #[must_use]
1427 pub const fn total(self) -> Duration {
1428 self.total
1429 }
1430}
1431
1432impl CargoBuildInput {
1433 #[must_use]
1435 pub fn label(&self) -> &Path {
1436 &self.label
1437 }
1438
1439 #[must_use]
1444 pub fn path(&self) -> &Path {
1445 &self.path
1446 }
1447}
1448
1449pub struct CargoInputProbePolicy<'a> {
1456 pub deadline: Option<Instant>,
1459 pub cleanup: CleanupPolicy,
1461 pub cancelled: &'a mut (dyn FnMut() -> bool + Send),
1464}
1465
1466impl ResolvedCargoBuildInputs {
1467 #[must_use]
1469 pub const fn facts(&self) -> &CargoBuildFacts {
1470 &self.facts
1471 }
1472
1473 fn assemble(
1474 spec: &WasmBuildSpec,
1475 inputs: ResolvedLocalInputs,
1476 exclusions: Vec<PathBuf>,
1477 input_digest: InputDigest,
1478 validation_digest: InputDigest,
1479 facts: CargoBuildFacts,
1480 timings: impl FnOnce() -> WasmInputResolutionTimings,
1481 ) -> Self {
1482 Self {
1483 fingerprint: finish_build_fingerprint(spec, &facts, input_digest),
1484 recipe_identity: resolution::recipe_identity(
1485 spec,
1486 &facts,
1487 inputs.configuration_identity,
1488 ),
1489 facts,
1490 input_digest,
1491 validation_digest,
1492 inputs: cargo_build_inputs(inputs.validation_inputs),
1493 base_exclusions: inputs.base_exclusions.into(),
1494 declared_package_files: inputs.declared_package_files.into(),
1495 exclusions: exclusions.into(),
1496 wasm_outputs: inputs.wasm_targets.map(|targets| ResolvedWasmOutputs {
1497 artifact_paths: expected_artifacts(spec, &spec.target_dir, &targets).into(),
1498 targets: targets.into(),
1499 }),
1500 timings: timings(),
1502 }
1503 }
1504
1505 pub fn artifact_paths(&self) -> Result<&[PathBuf], WasmBuildError> {
1515 self.wasm_outputs()
1516 .map(|outputs| outputs.artifact_paths.as_ref())
1517 }
1518
1519 fn wasm_outputs(&self) -> Result<&ResolvedWasmOutputs, WasmBuildError> {
1520 self.wasm_outputs
1521 .as_ref()
1522 .map_err(|message| WasmBuildError::InvalidSpec {
1523 message: message.clone(),
1524 })
1525 }
1526
1527 #[must_use]
1529 pub const fn fingerprint(&self) -> InputDigest {
1530 self.fingerprint
1531 }
1532
1533 #[must_use]
1549 pub const fn recipe_identity(&self) -> InputDigest {
1550 self.recipe_identity
1551 }
1552
1553 #[must_use]
1558 pub const fn input_digest(&self) -> InputDigest {
1559 self.input_digest
1560 }
1561
1562 #[must_use]
1567 pub const fn validation_digest(&self) -> InputDigest {
1568 self.validation_digest
1569 }
1570
1571 #[must_use]
1573 pub fn inputs(&self) -> &[CargoBuildInput] {
1574 &self.inputs
1575 }
1576
1577 #[must_use]
1584 pub fn exclusions(&self) -> &[PathBuf] {
1585 &self.exclusions
1586 }
1587
1588 #[must_use]
1590 pub const fn timings(&self) -> WasmInputResolutionTimings {
1591 self.timings
1592 }
1593
1594 pub fn is_current(&self, spec: &WasmBuildSpec) -> Result<bool, WasmBuildError> {
1599 resolve_cargo_build_inputs(spec).map(|current| current.fingerprint == self.fingerprint)
1600 }
1601
1602 pub fn is_current_with_probe_policy(
1610 &self,
1611 spec: &WasmBuildSpec,
1612 policy: &mut CargoInputProbePolicy<'_>,
1613 ) -> Result<bool, WasmBuildError> {
1614 resolve_cargo_build_inputs_with_probe_policy(spec, policy)
1615 .map(|current| current.fingerprint == self.fingerprint)
1616 }
1617
1618 pub fn is_content_current(&self) -> Result<bool, WasmBuildError> {
1628 self.current_validation_digest()
1629 .map(|current| current == self.validation_digest)
1630 }
1631
1632 pub(super) fn current_validation_digest(&self) -> Result<InputDigest, WasmBuildError> {
1633 let digest = || {
1634 let mut exclusions = self.base_exclusions.to_vec();
1635 source_inputs::exclude_nested_workspace_targets(
1636 self.inputs.iter().map(|input| input.path.as_path()),
1637 &self.declared_package_files,
1638 &mut exclusions,
1639 )?;
1640 digest_labeled_paths_composable(
1641 "wasm-source-inputs-v1",
1642 self.inputs
1643 .iter()
1644 .map(|input| (input.label.as_path(), input.path.as_path())),
1645 &exclusions,
1646 &mut LabeledPathDigestCache::default(),
1647 )
1648 };
1649 digest().map_err(|source| WasmBuildError::Io {
1650 operation: "rehash resolved Cargo build inputs",
1651 path: self
1652 .inputs
1653 .first()
1654 .map_or_else(PathBuf::new, |input| input.path.clone()),
1655 source,
1656 })
1657 }
1658}
1659
1660impl WasmBuildInputSnapshotState {
1661 pub(super) fn prepare(specs: &[WasmBuildSpec]) -> Result<Self, WasmBuildError> {
1662 for spec in specs {
1663 validate_spec(spec)?;
1664 }
1665 let mut resolver = WasmBuildBatchInputResolver::new(specs, None)
1666 .map_err(|_| WasmBuildError::PreparedInputSnapshotInvalidated)?;
1667 let mut snapshots = Vec::with_capacity(specs.len());
1668 let mut preparation_timings = WasmInputResolutionTimings::default();
1669 let mut progress = ProgressReporter::silent();
1670 for (index, spec) in specs.iter().enumerate() {
1671 let mut prepared_input = resolver.resolve(index, &mut progress)?;
1672 preparation_timings.include(prepared_input.timings);
1673 prepared_input.timings = WasmInputResolutionTimings::default();
1674 snapshots.push((spec.clone(), prepared_input));
1675 }
1676 Ok(Self {
1677 snapshots,
1678 preparation_metrics: resolver.metrics(),
1679 preparation_timings,
1680 reader_reuses: AtomicUsize::new(0),
1681 invalidation: Arc::new(RwLock::new(false)),
1682 })
1683 }
1684
1685 fn admit(&self, specs: &[&WasmBuildSpec]) -> Result<Vec<ResolvedCargoBuildInputs>, usize> {
1686 let matched = specs
1687 .iter()
1688 .enumerate()
1689 .map(|(index, spec)| {
1690 self.snapshots
1691 .iter()
1692 .find(|(candidate, _)| candidate == *spec)
1693 .map(|(_, snapshot)| snapshot.clone())
1694 .ok_or(index)
1695 })
1696 .collect::<Result<Vec<_>, _>>()?;
1697 #[allow(deprecated, reason = "try_update is unstable at the Rust 1.91 MSRV")]
1698 let _ = self
1699 .reader_reuses
1700 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
1701 Some(current.saturating_add(matched.len()))
1702 });
1703 Ok(matched)
1704 }
1705
1706 pub(super) const fn specification_count(&self) -> usize {
1707 self.snapshots.len()
1708 }
1709
1710 pub(super) const fn preparation_metrics(&self) -> WasmBuildBatchInputMetrics {
1711 self.preparation_metrics
1712 }
1713
1714 pub(super) const fn preparation_timings(&self) -> WasmInputResolutionTimings {
1715 self.preparation_timings
1716 }
1717
1718 pub(super) fn reader_reuses(&self) -> usize {
1719 self.reader_reuses.load(Ordering::Relaxed)
1720 }
1721
1722 pub(super) fn is_invalidated(&self) -> bool {
1723 *self
1724 .invalidation
1725 .read()
1726 .unwrap_or_else(std::sync::PoisonError::into_inner)
1727 }
1728
1729 fn invalidate(&self) {
1730 *self
1731 .invalidation
1732 .write()
1733 .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
1734 }
1735
1736 fn invalidation(&self) -> Arc<RwLock<bool>> {
1737 Arc::clone(&self.invalidation)
1738 }
1739}
1740
1741impl<'a, 'session> WasmBuildBatchInputResolver<'a, 'session> {
1742 pub(super) fn new(
1743 specs: impl IntoIterator<Item = &'a WasmBuildSpec>,
1744 reuse: Option<&'session WasmBuildInputSnapshotState>,
1745 ) -> Result<Self, usize> {
1746 let specs = specs.into_iter().collect::<Vec<_>>();
1747 let prepared = match reuse.as_ref() {
1748 Some(snapshot) => Some(snapshot.admit(&specs)?),
1749 _ => None,
1750 };
1751 let mut groups_by_key = HashMap::new();
1752 let mut groups = Vec::<BatchResolutionGroup>::new();
1753 let mut group_by_index = Vec::with_capacity(specs.len());
1754 for (index, spec) in specs.iter().copied().enumerate() {
1755 let group = *groups_by_key
1756 .entry(BatchResolutionKey::for_spec(spec))
1757 .or_insert_with(|| {
1758 groups.push(BatchResolutionGroup {
1759 indexes: Vec::new(),
1760 });
1761 groups.len() - 1
1762 });
1763 groups[group].indexes.push(index);
1764 group_by_index.push(group);
1765 }
1766 let mut metrics = WasmBuildBatchInputMetrics::default();
1767 let resolved = prepared.map_or_else(
1768 || specs.iter().map(|_| None).collect(),
1769 |prepared| {
1770 metrics.prepared_reuses = prepared.len();
1771 prepared
1772 .into_iter()
1773 .map(|snapshot| Some(Ok(snapshot)))
1774 .collect()
1775 },
1776 );
1777 Ok(Self {
1778 specs,
1779 groups,
1780 group_by_index,
1781 resolved,
1782 reuse,
1783 metrics,
1784 })
1785 }
1786
1787 pub(super) const fn metrics(&self) -> WasmBuildBatchInputMetrics {
1788 self.metrics
1789 }
1790
1791 pub(super) fn invalidate_source_lease(&self) {
1792 if let Some(snapshot) = self.reuse {
1793 snapshot.invalidate();
1794 }
1795 }
1796
1797 pub(super) fn prepared_invalidation(&self) -> Option<Arc<RwLock<bool>>> {
1798 self.reuse.map(WasmBuildInputSnapshotState::invalidation)
1799 }
1800
1801 fn resolve(
1802 &mut self,
1803 index: usize,
1804 progress: &mut ProgressReporter<'_>,
1805 ) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1806 if self.resolved[index].is_none() {
1807 self.resolve_group(index, progress)?;
1808 }
1809 self.resolved[index]
1810 .take()
1811 .expect("resolved batch input must be populated")
1812 .map_err(|(phase, error)| {
1813 progress.begin_phase(phase);
1814 error
1815 })
1816 }
1817
1818 fn resolve_group(
1819 &mut self,
1820 active_index: usize,
1821 progress: &mut ProgressReporter<'_>,
1822 ) -> Result<(), WasmBuildError> {
1823 let total_started = Instant::now();
1824 let group = self.group_by_index[active_index];
1825 let active = self.specs[active_index];
1826
1827 let (facts, metadata, mut timings) = observe_cargo_resolution(active, progress, &mut None)?;
1828
1829 let (discovered, input_discovery) = self.discover_group_inputs(group, &metadata, progress);
1830
1831 let hashing_started = Instant::now();
1832 let mut batch_digest_cache = LabeledPathDigestCache::default();
1833 let digest_cache = &mut batch_digest_cache;
1834 let workspace_root = &active.workspace_root;
1835 let resolved_inputs = progress.run_phase(WasmBuildPhase::ContentHashing, || {
1836 discovered
1837 .into_iter()
1838 .map(|(index, inputs, mut exclusions)| {
1839 let result = digest_resolved_local_inputs(
1840 &inputs,
1841 &mut exclusions,
1842 digest_cache,
1843 workspace_root,
1844 "hash batched Wasm build inputs",
1845 "hash batched semantic Wasm build inputs",
1846 )
1847 .map(|(input_digest, validation_digest)| {
1848 (inputs, exclusions, input_digest, validation_digest)
1849 });
1850 (index, result)
1851 })
1852 .collect::<Vec<_>>()
1853 });
1854 let content_hashing = hashing_started.elapsed();
1855 timings.input_discovery = input_discovery;
1856 timings.content_hashing = content_hashing;
1857 timings.total = total_started.elapsed();
1858 let resolved_count = resolved_inputs
1859 .iter()
1860 .filter(|(_, result)| result.is_ok())
1861 .count();
1862 self.metrics.runs += usize::from(resolved_count > 0);
1863 self.metrics.reuses += resolved_count.saturating_sub(1);
1864 let timing_index = resolved_inputs
1865 .iter()
1866 .find(|(index, result)| *index == active_index && result.is_ok())
1867 .or_else(|| resolved_inputs.iter().find(|(_, result)| result.is_ok()))
1868 .map(|(index, _)| *index);
1869 for (index, result) in resolved_inputs {
1870 let (inputs, exclusions, input_digest, validation_digest) = match result {
1871 Ok(resolved) => resolved,
1872 Err(error) => {
1873 self.resolved[index] = Some(Err((WasmBuildPhase::ContentHashing, error)));
1874 continue;
1875 }
1876 };
1877 let spec = self.specs[index];
1878 let resolved = ResolvedCargoBuildInputs::assemble(
1879 spec,
1880 inputs,
1881 exclusions,
1882 input_digest,
1883 validation_digest,
1884 spec.cargo_invocation()
1885 .facts(facts.cargo_identity.clone(), facts.rustc_identity.clone()),
1886 || {
1887 if Some(index) == timing_index {
1888 timings
1889 } else {
1890 WasmInputResolutionTimings::default()
1891 }
1892 },
1893 );
1894 self.resolved[index] = Some(Ok(resolved));
1895 }
1896 Ok(())
1897 }
1898
1899 fn discover_group_inputs(
1900 &mut self,
1901 group: usize,
1902 metadata: &schema::Metadata,
1903 progress: &mut ProgressReporter<'_>,
1904 ) -> (Vec<(usize, ResolvedLocalInputs, Vec<PathBuf>)>, Duration) {
1905 let started = Instant::now();
1906 let indexes = &self.groups[group].indexes;
1907 let specs = &self.specs;
1908 let resolved = &mut self.resolved;
1909 let discovered = progress.run_phase(WasmBuildPhase::InputDiscovery, || {
1910 let parsed = ParsedCargoMetadata::parse(metadata);
1911 let mut discovered = Vec::new();
1912 for &index in indexes {
1913 if resolved[index].is_some() || validate_spec(specs[index]).is_err() {
1914 continue;
1915 }
1916 let result = parsed
1917 .as_ref()
1918 .map_err(|message| invalid_metadata(message))
1919 .and_then(|parsed| resolve_local_inputs(specs[index], parsed));
1920 match result {
1921 Ok((inputs, exclusions)) => discovered.push((index, inputs, exclusions)),
1922 Err(error) => {
1923 resolved[index] = Some(Err((WasmBuildPhase::InputDiscovery, error)));
1924 }
1925 }
1926 }
1927 discovered
1928 });
1929 (discovered, started.elapsed())
1930 }
1931}
1932
1933fn resolve_tool_identity_with_policy(
1934 spec: &WasmBuildSpec,
1935 progress: &mut ProgressReporter<'_>,
1936 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
1937) -> Result<(CargoBuildFacts, Duration), WasmBuildError> {
1938 let started = Instant::now();
1939 let facts = spec
1940 .cargo_invocation()
1941 .observe(&spec.rustc_program, |tool, command| {
1942 let phase = match tool {
1943 CargoTool::Cargo => WasmBuildPhase::CargoIdentity,
1944 CargoTool::Rustc => WasmBuildPhase::RustcIdentity,
1945 };
1946 progress.run_phase(phase, || {
1947 let output =
1948 capture_probe_output_with_policy(command, phase, TOOL_IDENTITY_BYTES, policy)?;
1949 ensure_command_success(phase, output)
1950 })
1951 })?;
1952 Ok((facts, started.elapsed()))
1953}
1954
1955fn observe_cargo_resolution(
1958 spec: &WasmBuildSpec,
1959 progress: &mut ProgressReporter<'_>,
1960 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
1961) -> Result<
1962 (
1963 CargoBuildFacts,
1964 schema::Metadata,
1965 WasmInputResolutionTimings,
1966 ),
1967 WasmBuildError,
1968> {
1969 let (facts, tool_identity) = resolve_tool_identity_with_policy(spec, progress, policy)?;
1970 let started = Instant::now();
1971 let metadata = progress.run_phase(WasmBuildPhase::CargoMetadata, || {
1972 cargo_metadata_with_policy(spec, policy)
1973 })?;
1974 Ok((
1975 facts,
1976 metadata,
1977 WasmInputResolutionTimings {
1978 tool_identity,
1979 cargo_metadata: started.elapsed(),
1980 ..WasmInputResolutionTimings::default()
1981 },
1982 ))
1983}
1984
1985impl<'a> BatchResolutionKey<'a> {
1986 fn for_spec(spec: &'a WasmBuildSpec) -> Self {
1987 Self {
1988 workspace_root: &spec.workspace_root,
1989 cargo_program: &spec.cargo_program,
1990 rustc_program: spec
1991 .environment
1992 .get(OsStr::new("RUSTC"))
1993 .unwrap_or(&spec.rustc_program),
1994 metadata_arguments: spec.cargo_selection.metadata_arguments(),
1995 environment: &spec.environment,
1996 }
1997 }
1998}
1999
2000impl SharedIncrementalTargetInspection {
2001 #[must_use]
2003 pub fn target_dir(&self) -> &Path {
2004 &self.target_dir
2005 }
2006
2007 #[must_use]
2009 pub const fn logical_size_bytes(&self) -> u64 {
2010 self.logical_size_bytes
2011 }
2012
2013 #[must_use]
2015 pub const fn last_used(&self) -> SystemTime {
2016 self.last_used
2017 }
2018
2019 #[must_use]
2021 pub const fn lock_wait(&self) -> Duration {
2022 self.lock_wait
2023 }
2024}
2025
2026impl SharedIncrementalTargetMaintenanceConfig {
2027 #[must_use]
2029 pub const fn new(policy: CacheRetentionLimits, minimum_interval: Duration) -> Self {
2030 Self {
2031 policy,
2032 minimum_interval,
2033 failure_mode: SharedIncrementalTargetMaintenanceFailureMode::Strict,
2034 }
2035 }
2036
2037 #[must_use]
2039 pub const fn with_failure_mode(
2040 mut self,
2041 failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
2042 ) -> Self {
2043 self.failure_mode = failure_mode;
2044 self
2045 }
2046
2047 #[must_use]
2049 pub const fn policy(self) -> CacheRetentionLimits {
2050 self.policy
2051 }
2052
2053 #[must_use]
2055 pub const fn minimum_interval(self) -> Duration {
2056 self.minimum_interval
2057 }
2058
2059 #[must_use]
2061 pub const fn failure_mode(self) -> SharedIncrementalTargetMaintenanceFailureMode {
2062 self.failure_mode
2063 }
2064}
2065
2066impl SharedIncrementalTargetMaintenance {
2067 #[must_use]
2069 pub fn target_dir(&self) -> &Path {
2070 &self.target_dir
2071 }
2072
2073 #[must_use]
2075 pub const fn logical_size_bytes_before(&self) -> u64 {
2076 self.logical_size_bytes_before
2077 }
2078
2079 #[must_use]
2081 pub const fn logical_size_bytes_after(&self) -> u64 {
2082 self.logical_size_bytes_after
2083 }
2084
2085 #[must_use]
2087 pub const fn last_used_before(&self) -> SystemTime {
2088 self.last_used_before
2089 }
2090
2091 #[must_use]
2093 pub const fn was_cleared(&self) -> bool {
2094 self.cleared
2095 }
2096
2097 #[must_use]
2099 pub const fn lock_wait(&self) -> Duration {
2100 self.lock_wait
2101 }
2102
2103 #[must_use]
2105 pub const fn maintenance(&self) -> Duration {
2106 self.maintenance
2107 }
2108}
2109
2110impl std::fmt::Display for SharedIncrementalTargetMaintenance {
2111 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2112 write!(
2113 formatter,
2114 "target={} action={} bytes={}=>{} lock={:?} maintenance={:?}",
2115 self.target_dir.display(),
2116 if self.cleared { "cleared" } else { "retained" },
2117 self.logical_size_bytes_before,
2118 self.logical_size_bytes_after,
2119 self.lock_wait,
2120 self.maintenance,
2121 )
2122 }
2123}
2124
2125impl SharedIncrementalTargetMaintenanceOutcome {
2126 #[must_use]
2128 pub fn target_dir(&self) -> &Path {
2129 match self {
2130 Self::Missing { target_dir }
2131 | Self::Skipped { target_dir, .. }
2132 | Self::Failed { target_dir, .. } => target_dir,
2133 Self::Performed { maintenance, .. } => maintenance.target_dir(),
2134 }
2135 }
2136
2137 #[must_use]
2139 pub const fn maintenance(&self) -> Option<&SharedIncrementalTargetMaintenance> {
2140 match self {
2141 Self::Performed { maintenance, .. } => Some(maintenance),
2142 Self::Missing { .. } | Self::Skipped { .. } | Self::Failed { .. } => None,
2143 }
2144 }
2145
2146 #[must_use]
2148 pub const fn was_performed(&self) -> bool {
2149 matches!(self, Self::Performed { .. })
2150 }
2151
2152 #[must_use]
2154 pub const fn lock_wait(&self) -> Option<Duration> {
2155 match self {
2156 Self::Missing { .. } => None,
2157 Self::Skipped { lock_wait, .. } | Self::Failed { lock_wait, .. } => Some(*lock_wait),
2158 Self::Performed { maintenance, .. } => Some(maintenance.lock_wait()),
2159 }
2160 }
2161
2162 #[must_use]
2164 pub const fn schedule_check(&self) -> Option<Duration> {
2165 match self {
2166 Self::Missing { .. } | Self::Failed { .. } => None,
2167 Self::Skipped { schedule_check, .. } | Self::Performed { schedule_check, .. } => {
2168 Some(*schedule_check)
2169 }
2170 }
2171 }
2172
2173 #[must_use]
2175 pub fn failure_message(&self) -> Option<&str> {
2176 match self {
2177 Self::Failed { message, .. } => Some(message),
2178 Self::Missing { .. } | Self::Skipped { .. } | Self::Performed { .. } => None,
2179 }
2180 }
2181}
2182
2183impl std::fmt::Display for SharedIncrementalTargetMaintenanceOutcome {
2184 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2185 match self {
2186 Self::Missing { target_dir } => {
2187 write!(formatter, "target={} action=missing", target_dir.display())
2188 }
2189 Self::Skipped {
2190 target_dir,
2191 lock_wait,
2192 schedule_check,
2193 } => write!(
2194 formatter,
2195 "target={} action=skipped lock={lock_wait:?} schedule={schedule_check:?}",
2196 target_dir.display(),
2197 ),
2198 Self::Performed {
2199 maintenance,
2200 schedule_check,
2201 } => write!(formatter, "{maintenance} schedule={schedule_check:?}"),
2202 Self::Failed {
2203 target_dir,
2204 lock_wait,
2205 message,
2206 } => write!(
2207 formatter,
2208 "target={} action=failed lock={lock_wait:?} error={message}",
2209 target_dir.display(),
2210 ),
2211 }
2212 }
2213}
2214
2215impl std::fmt::Display for WasmBuildTimings {
2216 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2217 write!(
2218 formatter,
2219 "total={:?} lock={:?} shared_lock={:?} inputs={:?} cargo={:?} maintenance={:?}",
2220 self.total,
2221 self.lock_wait,
2222 self.shared_incremental_lock_wait,
2223 self.input_resolution.total,
2224 self.cargo_build,
2225 self.cache_maintenance,
2226 )
2227 }
2228}
2229
2230impl std::fmt::Display for WasmBuildOutcome {
2231 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2232 let state = if self.is_reused() { "reused" } else { "built" };
2233 write!(
2234 formatter,
2235 "{state} fingerprint={} artifacts={} {}",
2236 self.record().fingerprint,
2237 self.record().artifacts.len(),
2238 self.record().timings,
2239 )?;
2240 if let Some(maintenance) = self.record().shared_incremental_maintenance() {
2241 write!(formatter, " shared_maintenance=({maintenance})")?;
2242 }
2243 Ok(())
2244 }
2245}
2246
2247pub fn resolve_cargo_build_inputs(
2261 spec: &WasmBuildSpec,
2262) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2263 validate_spec(spec)?;
2264 build_fingerprint(spec)
2265}
2266
2267pub fn resolve_cargo_build_inputs_with_probe_policy(
2279 spec: &WasmBuildSpec,
2280 policy: &mut CargoInputProbePolicy<'_>,
2281) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2282 validate_spec(spec)?;
2283 build_fingerprint_with_policy(spec, &mut ProgressReporter::silent(), &mut Some(policy))
2284}
2285
2286pub fn inspect_shared_incremental_target(
2294 spec: &WasmBuildSpec,
2295) -> Result<Option<SharedIncrementalTargetInspection>, WasmBuildError> {
2296 if !shared_incremental_target_exists(spec, "inspect shared incremental Cargo target")? {
2297 return Ok(None);
2298 }
2299
2300 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2301 let logical_size_bytes =
2302 directory_logical_size(&canonical).map_err(|source| WasmBuildError::Io {
2303 operation: "measure shared incremental Cargo target",
2304 path: canonical.clone(),
2305 source,
2306 })?;
2307 let last_used = cache_entry_last_used(&canonical).map_err(|source| WasmBuildError::Io {
2308 operation: "read shared incremental Cargo target use time",
2309 path: canonical.clone(),
2310 source,
2311 })?;
2312 Ok(Some(SharedIncrementalTargetInspection {
2313 target_dir: canonical,
2314 logical_size_bytes,
2315 last_used,
2316 lock_wait,
2317 }))
2318}
2319
2320pub fn maintain_shared_incremental_target(
2338 spec: &WasmBuildSpec,
2339 policy: CacheRetentionLimits,
2340) -> Result<Option<SharedIncrementalTargetMaintenance>, WasmBuildError> {
2341 if !shared_incremental_target_exists(
2342 spec,
2343 "inspect shared incremental Cargo target before maintenance",
2344 )? {
2345 return Ok(None);
2346 }
2347
2348 let _ = resolve_cargo_build_inputs(spec)?;
2352 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2353 maintain_shared_incremental_target_locked(&canonical, policy, lock_wait).map(Some)
2354}
2355
2356pub fn maintain_shared_incremental_target_at_most_every(
2374 spec: &WasmBuildSpec,
2375 policy: CacheRetentionLimits,
2376 minimum_interval: Duration,
2377) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2378 let target_dir =
2379 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
2380 message: "shared incremental target is not configured".to_owned(),
2381 })?;
2382 if !shared_incremental_target_exists(
2383 spec,
2384 "inspect shared incremental Cargo target before scheduled maintenance",
2385 )? {
2386 return Ok(SharedIncrementalTargetMaintenanceOutcome::Missing { target_dir });
2387 }
2388
2389 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2390 let schedule = schedule_shared_incremental_target_maintenance(
2391 &canonical,
2392 policy,
2393 minimum_interval,
2394 lock_wait,
2395 )?;
2396 let schedule = match schedule {
2397 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => return Ok(outcome),
2398 SharedIncrementalTargetMaintenanceSchedule::Due(due) => due,
2399 };
2400
2401 let _ = resolve_cargo_build_inputs(spec)?;
2404 perform_due_shared_incremental_target_maintenance(&canonical, policy, lock_wait, schedule)
2405}
2406
2407enum SharedIncrementalTargetMaintenanceSchedule {
2408 Skipped(SharedIncrementalTargetMaintenanceOutcome),
2409 Due(DueSharedIncrementalTargetMaintenance),
2410}
2411
2412struct DueSharedIncrementalTargetMaintenance {
2413 schedule_root: PathBuf,
2414 maintenance_identity: String,
2415 schedule_check: Duration,
2416}
2417
2418fn schedule_shared_incremental_target_maintenance(
2419 canonical: &Path,
2420 policy: CacheRetentionLimits,
2421 minimum_interval: Duration,
2422 lock_wait: Duration,
2423) -> Result<SharedIncrementalTargetMaintenanceSchedule, WasmBuildError> {
2424 let schedule_root = canonical.join(".ic-testkit");
2425 let maintenance_identity = policy.maintenance_identity();
2426 let schedule_started = Instant::now();
2427 let due = cache_maintenance_due(&schedule_root, minimum_interval, &maintenance_identity)
2428 .map_err(wasm_cache_fs_error)?;
2429 let schedule_check = schedule_started.elapsed();
2430 if !due {
2431 return Ok(SharedIncrementalTargetMaintenanceSchedule::Skipped(
2432 SharedIncrementalTargetMaintenanceOutcome::Skipped {
2433 target_dir: canonical.to_owned(),
2434 lock_wait,
2435 schedule_check,
2436 },
2437 ));
2438 }
2439 Ok(SharedIncrementalTargetMaintenanceSchedule::Due(
2440 DueSharedIncrementalTargetMaintenance {
2441 schedule_root,
2442 maintenance_identity,
2443 schedule_check,
2444 },
2445 ))
2446}
2447
2448fn perform_due_shared_incremental_target_maintenance(
2449 canonical: &Path,
2450 policy: CacheRetentionLimits,
2451 lock_wait: Duration,
2452 due: DueSharedIncrementalTargetMaintenance,
2453) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2454 let DueSharedIncrementalTargetMaintenance {
2455 schedule_root,
2456 maintenance_identity,
2457 schedule_check,
2458 } = due;
2459 let maintenance = maintain_shared_incremental_target_locked(canonical, policy, lock_wait)?;
2460 record_cache_maintenance(&schedule_root, &maintenance_identity).map_err(wasm_cache_fs_error)?;
2461 Ok(SharedIncrementalTargetMaintenanceOutcome::Performed {
2462 maintenance,
2463 schedule_check,
2464 })
2465}
2466
2467fn maintain_shared_incremental_target_locked(
2468 canonical: &Path,
2469 policy: CacheRetentionLimits,
2470 lock_wait: Duration,
2471) -> Result<SharedIncrementalTargetMaintenance, WasmBuildError> {
2472 let started = Instant::now();
2473 let logical_size_bytes_before =
2474 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2475 operation: "measure shared incremental Cargo target before maintenance",
2476 path: canonical.to_owned(),
2477 source,
2478 })?;
2479 let last_used_before =
2480 cache_entry_last_used(canonical).map_err(|source| WasmBuildError::Io {
2481 operation: "read shared incremental Cargo target use time before maintenance",
2482 path: canonical.to_owned(),
2483 source,
2484 })?;
2485 let expired = policy.max_age().is_some_and(|max_age| {
2486 SystemTime::now()
2487 .duration_since(last_used_before)
2488 .is_ok_and(|age| age > max_age)
2489 });
2490 let oversized = policy
2491 .max_size_bytes()
2492 .is_some_and(|max_size_bytes| logical_size_bytes_before > max_size_bytes);
2493 let cleared = expired || oversized;
2494 if cleared {
2495 clear_shared_incremental_target_contents(canonical)?;
2496 record_cache_entry_use(canonical)?;
2497 }
2498 let logical_size_bytes_after = if cleared {
2499 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2500 operation: "measure shared incremental Cargo target after maintenance",
2501 path: canonical.to_owned(),
2502 source,
2503 })?
2504 } else {
2505 logical_size_bytes_before
2506 };
2507 Ok(SharedIncrementalTargetMaintenance {
2508 target_dir: canonical.to_owned(),
2509 logical_size_bytes_before,
2510 logical_size_bytes_after,
2511 last_used_before,
2512 cleared,
2513 lock_wait,
2514 maintenance: started.elapsed(),
2515 })
2516}
2517
2518fn clear_shared_incremental_target_contents(target_dir: &Path) -> Result<(), WasmBuildError> {
2519 let entries = fs::read_dir(target_dir).map_err(|source| WasmBuildError::Io {
2520 operation: "read shared incremental Cargo target for maintenance",
2521 path: target_dir.to_owned(),
2522 source,
2523 })?;
2524 for entry in entries {
2525 let path = entry
2526 .map_err(|source| WasmBuildError::Io {
2527 operation: "read shared incremental Cargo target entry for maintenance",
2528 path: target_dir.to_owned(),
2529 source,
2530 })?
2531 .path();
2532 let preserved = path
2533 .file_name()
2534 .is_some_and(|name| name == ".ic-testkit" || name == "CACHEDIR.TAG");
2535 if !preserved {
2536 remove_path_if_present(&path).map_err(|source| WasmBuildError::Io {
2537 operation: "clear shared incremental Cargo target entry",
2538 path,
2539 source,
2540 })?;
2541 }
2542 }
2543 Ok(())
2544}
2545
2546pub fn build_wasm_canisters_cached(
2577 spec: &WasmBuildSpec,
2578 mut spawn: impl FnMut(&mut Command) -> io::Result<OwnedChild>,
2579) -> Result<WasmBuildOutcome, WasmBuildError> {
2580 build_wasm_canisters_cached_internal(spec, &mut ProgressReporter::silent(), None, &mut spawn)
2581}
2582
2583pub fn build_wasm_canisters_cached_with_progress<F>(
2592 spec: &WasmBuildSpec,
2593 config: WasmBuildProgressConfig,
2594 mut observer: F,
2595 mut spawn: impl FnMut(&mut Command) -> io::Result<OwnedChild>,
2596) -> Result<WasmBuildOutcome, WasmBuildError>
2597where
2598 F: FnMut(WasmBuildProgressEvent),
2599{
2600 build_wasm_canisters_cached_internal(
2601 spec,
2602 &mut ProgressReporter::new(Some((config, &mut observer)))?,
2603 None,
2604 &mut spawn,
2605 )
2606}
2607
2608#[expect(
2609 clippy::result_large_err,
2610 reason = "Batch entries retain complete failure details inline without per-failure allocation."
2611)]
2612pub(super) fn build_wasm_canisters_cached_in_batch(
2613 spec: &WasmBuildSpec,
2614 index: usize,
2615 resolver: &mut WasmBuildBatchInputResolver<'_, '_>,
2616 observation: Option<(
2617 WasmBuildProgressConfig,
2618 &mut dyn FnMut(WasmBuildProgressEvent),
2619 )>,
2620 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
2621) -> Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)> {
2622 let mut progress = ProgressReporter::new(observation).map_err(|error| {
2623 (
2624 error,
2625 WasmBuildFailureDetails::specification(Duration::ZERO),
2626 )
2627 })?;
2628 let started = Instant::now();
2629 let result = build_wasm_canisters_cached_internal(
2630 spec,
2631 &mut progress,
2632 Some((resolver, index)),
2633 &mut spawn,
2634 );
2635 batch_result(result, &progress, resolver, started)
2636}
2637
2638#[expect(
2639 clippy::result_large_err,
2640 reason = "Batch entries retain complete failure details inline without per-failure allocation."
2641)]
2642fn batch_result(
2643 result: Result<WasmBuildOutcome, WasmBuildError>,
2644 progress: &ProgressReporter<'_>,
2645 resolver: &WasmBuildBatchInputResolver<'_, '_>,
2646 started: Instant,
2647) -> Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)> {
2648 if result
2649 .as_ref()
2650 .is_err_and(WasmBuildError::indicates_input_change)
2651 {
2652 resolver.invalidate_source_lease();
2653 }
2654 let total = started.elapsed();
2655 result.map_err(|error| {
2656 let details = progress.failure_details(&error, total);
2657 (error, details)
2658 })
2659}
2660
2661fn batch_prepared_invalidation(
2662 batch_resolution: Option<&(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2663) -> Option<Arc<RwLock<bool>>> {
2664 batch_resolution.and_then(|(resolver, _)| resolver.prepared_invalidation())
2665}
2666
2667fn build_wasm_canisters_cached_internal(
2668 spec: &WasmBuildSpec,
2669 progress: &mut ProgressReporter<'_>,
2670 mut batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2671 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
2672) -> Result<WasmBuildOutcome, WasmBuildError> {
2673 let total_started = Instant::now();
2674 validate_spec(spec)?;
2675 let prepared_invalidation = batch_prepared_invalidation(batch_resolution.as_ref());
2676 progress.emit(WasmBuildProgressEvent::Started);
2677 if spec.shared_incremental_maintenance_config.is_some() {
2678 let outcome = build_wasm_with_shared_incremental(
2679 spec,
2680 None,
2681 prepared_invalidation,
2682 total_started,
2683 progress,
2684 batch_resolution.take(),
2685 &mut spawn,
2686 )?;
2687 emit_finished_progress(&outcome, progress);
2688 return Ok(outcome);
2689 }
2690 let (cache_lock, first_lock_wait) =
2691 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2692 ensure_cache_directory_tag(&spec.target_dir)?;
2693
2694 let resolved = resolve_initial_inputs(spec, batch_resolution.take(), progress)?;
2695 let isolated_acquisition = WasmAcquisitionContext::isolated(prepared_invalidation);
2696 if let Some(outcome) = try_reuse_wasm_artifacts(
2697 spec,
2698 &resolved,
2699 first_lock_wait,
2700 &isolated_acquisition,
2701 total_started,
2702 progress,
2703 )? {
2704 emit_finished_progress(&outcome, progress);
2705 return Ok(outcome);
2706 }
2707 progress.emit(WasmBuildProgressEvent::CacheMiss {
2708 fingerprint: resolved.fingerprint,
2709 });
2710
2711 let outcome = match &spec.cache_mode {
2712 WasmBuildCacheMode::Isolated => {
2713 let cache_entry = cache_entry_directory(spec, resolved.fingerprint);
2714 build_wasm_cache_miss(
2715 spec,
2716 &resolved,
2717 first_lock_wait,
2718 isolated_acquisition,
2719 &cache_entry,
2720 total_started,
2721 progress,
2722 &mut spawn,
2723 )
2724 }
2725 WasmBuildCacheMode::SharedIncremental { .. } => {
2726 drop(cache_lock);
2727 build_wasm_with_shared_incremental(
2728 spec,
2729 Some((resolved, first_lock_wait)),
2730 isolated_acquisition.prepared_invalidation,
2731 total_started,
2732 progress,
2733 None,
2734 &mut spawn,
2735 )
2736 }
2737 }?;
2738 emit_finished_progress(&outcome, progress);
2739 Ok(outcome)
2740}
2741
2742fn build_wasm_with_shared_incremental(
2743 spec: &WasmBuildSpec,
2744 initial: Option<(ResolvedCargoBuildInputs, Duration)>,
2745 prepared_invalidation: Option<Arc<RwLock<bool>>>,
2746 total_started: Instant,
2747 progress: &mut ProgressReporter<'_>,
2748 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2749 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
2750) -> Result<WasmBuildOutcome, WasmBuildError> {
2751 let (shared_lock, shared_lock_wait, shared_target) =
2752 lock_shared_incremental_target_with_progress(spec, progress)?;
2753 let (_cache_lock, second_lock_wait) =
2754 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2755 ensure_cache_directory_tag(&spec.target_dir)?;
2756
2757 let emit_cache_miss = initial.is_none();
2758 let (current, first_lock_wait) = match initial {
2762 Some((resolved, wait)) if prepared_invalidation.is_some() => (resolved, wait),
2763 Some((resolved, wait)) => {
2764 let mut current = resolve_initial_inputs(spec, None, progress)?;
2765 current.timings.include(resolved.timings);
2766 (current, wait)
2767 }
2768 None => (
2769 resolve_initial_inputs(spec, batch_resolution, progress)?,
2770 Duration::ZERO,
2771 ),
2772 };
2773 let lock_wait = first_lock_wait.saturating_add(second_lock_wait);
2774 let maintenance = spec
2775 .shared_incremental_maintenance_config
2776 .map(|_| {
2777 perform_configured_shared_incremental_target_maintenance(
2778 spec,
2779 &shared_target,
2780 shared_lock_wait,
2781 progress,
2782 )
2783 })
2784 .transpose()?;
2785 let shared_incremental =
2786 WasmAcquisitionContext::shared(shared_lock_wait, maintenance, prepared_invalidation);
2787 if let Some(outcome) = try_reuse_wasm_artifacts(
2788 spec,
2789 ¤t,
2790 lock_wait,
2791 &shared_incremental,
2792 total_started,
2793 progress,
2794 )? {
2795 return Ok(outcome);
2796 }
2797 if emit_cache_miss {
2798 progress.emit(WasmBuildProgressEvent::CacheMiss {
2799 fingerprint: current.fingerprint,
2800 });
2801 }
2802
2803 let outcome = build_wasm_cache_miss(
2804 spec,
2805 ¤t,
2806 lock_wait,
2807 shared_incremental,
2808 &shared_target,
2809 total_started,
2810 progress,
2811 &mut spawn,
2812 );
2813 drop(shared_lock);
2814 outcome
2815}
2816
2817fn perform_configured_shared_incremental_target_maintenance(
2818 spec: &WasmBuildSpec,
2819 shared_target: &Path,
2820 lock_wait: Duration,
2821 progress: &mut ProgressReporter<'_>,
2822) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2823 let config = spec
2824 .shared_incremental_maintenance_config
2825 .expect("configured shared-target maintenance must have settings");
2826 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceStarted {
2827 target_dir: shared_target.to_owned(),
2828 });
2829 let result = progress.run_phase(WasmBuildPhase::SharedTargetMaintenance, || {
2830 let schedule = schedule_shared_incremental_target_maintenance(
2831 shared_target,
2832 config.policy,
2833 config.minimum_interval,
2834 lock_wait,
2835 )?;
2836 match schedule {
2837 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => Ok(outcome),
2838 SharedIncrementalTargetMaintenanceSchedule::Due(due) => {
2839 perform_due_shared_incremental_target_maintenance(
2840 shared_target,
2841 config.policy,
2842 lock_wait,
2843 due,
2844 )
2845 }
2846 }
2847 });
2848 let outcome = integrated_shared_maintenance_result(config, shared_target, lock_wait, result)?;
2849 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceFinished {
2850 outcome: outcome.clone(),
2851 });
2852 Ok(outcome)
2853}
2854
2855fn integrated_shared_maintenance_result(
2856 config: SharedIncrementalTargetMaintenanceConfig,
2857 shared_target: &Path,
2858 lock_wait: Duration,
2859 result: Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError>,
2860) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2861 match result {
2862 Ok(outcome) => Ok(outcome),
2863 Err(error)
2864 if config.failure_mode == SharedIncrementalTargetMaintenanceFailureMode::BestEffort =>
2865 {
2866 Ok(SharedIncrementalTargetMaintenanceOutcome::Failed {
2867 target_dir: shared_target.to_owned(),
2868 lock_wait,
2869 message: error.to_string(),
2870 })
2871 }
2872 Err(error) => Err(error),
2873 }
2874}
2875
2876fn resolve_initial_inputs(
2877 spec: &WasmBuildSpec,
2878 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2879 progress: &mut ProgressReporter<'_>,
2880) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2881 let resolved = if let Some((resolver, index)) = batch_resolution {
2882 resolver.resolve(index, progress)?
2883 } else {
2884 build_fingerprint_with_progress(spec, progress)?
2885 };
2886 resolved.wasm_outputs().inspect_err(|_| {
2887 progress.begin_phase(WasmBuildPhase::InputDiscovery);
2888 })?;
2889 progress.emit(WasmBuildProgressEvent::InputsResolved {
2890 fingerprint: resolved.fingerprint,
2891 input_digest: resolved.input_digest,
2892 elapsed: resolved.timings.total,
2893 });
2894 Ok(resolved)
2895}
2896
2897fn emit_finished_progress(outcome: &WasmBuildOutcome, progress: &mut ProgressReporter<'_>) {
2898 let state = if outcome.is_reused() {
2899 progress.emit(WasmBuildProgressEvent::CacheHit {
2900 fingerprint: outcome.record().fingerprint,
2901 });
2902 WasmBuildProgressOutcome::Reused
2903 } else {
2904 WasmBuildProgressOutcome::Built
2905 };
2906 progress.emit(WasmBuildProgressEvent::Finished {
2907 outcome: state,
2908 fingerprint: outcome.record().fingerprint,
2909 elapsed: outcome.record().timings.total,
2910 });
2911}
2912
2913#[derive(Clone, Debug, Default)]
2914struct WasmAcquisitionContext {
2915 lock_wait: Option<Duration>,
2916 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
2917 prepared_invalidation: Option<Arc<RwLock<bool>>>,
2918}
2919
2920impl WasmAcquisitionContext {
2921 fn isolated(prepared_invalidation: Option<Arc<RwLock<bool>>>) -> Self {
2922 Self {
2923 prepared_invalidation,
2924 ..Self::default()
2925 }
2926 }
2927
2928 const fn shared(
2929 lock_wait: Duration,
2930 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
2931 prepared_invalidation: Option<Arc<RwLock<bool>>>,
2932 ) -> Self {
2933 Self {
2934 lock_wait: Some(lock_wait),
2935 maintenance,
2936 prepared_invalidation,
2937 }
2938 }
2939
2940 fn lock_prepared_publication(
2941 &self,
2942 ) -> Result<Option<std::sync::RwLockReadGuard<'_, bool>>, WasmBuildError> {
2943 let guard = self.prepared_invalidation.as_deref().map(|invalidation| {
2944 invalidation
2945 .read()
2946 .unwrap_or_else(std::sync::PoisonError::into_inner)
2947 });
2948 if guard.as_deref().is_some_and(|invalidated| *invalidated) {
2949 return Err(WasmBuildError::PreparedInputSnapshotInvalidated);
2950 }
2951 Ok(guard)
2952 }
2953}
2954
2955fn try_reuse_wasm_artifacts(
2956 spec: &WasmBuildSpec,
2957 resolved: &ResolvedCargoBuildInputs,
2958 lock_wait: Duration,
2959 shared_incremental: &WasmAcquisitionContext,
2960 total_started: Instant,
2961 progress: &mut ProgressReporter<'_>,
2962) -> Result<Option<WasmBuildOutcome>, WasmBuildError> {
2963 let targets = &resolved.wasm_outputs()?.targets;
2964 let _publication_guard = shared_incremental.lock_prepared_publication()?;
2965 let fingerprint = resolved.fingerprint;
2966 let cache_entry = cache_entry_directory(spec, fingerprint);
2967 let paths = artifact_publication_paths(spec, &cache_entry, targets);
2968 let Some(artifact_info) = progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
2969 validated_artifact_set(paths, fingerprint)
2970 }) else {
2971 return Ok(None);
2972 };
2973 progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
2974 materialize_artifacts(&artifact_info, fingerprint, true)?;
2975 record_cache_entry_use(&cache_entry)
2976 })?;
2977 let input_resolution = validate_reused_inputs(spec, resolved, shared_incremental, progress)?;
2978 Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
2979 spec,
2980 BuildRecordInput {
2981 resolved,
2982 lock_wait,
2983 shared_incremental: shared_incremental.clone(),
2984 input_resolution,
2985 cargo_build: None,
2986 active_entry: &cache_entry,
2987 targets,
2988 },
2989 total_started,
2990 progress,
2991 )?)))
2992}
2993
2994fn validate_reused_inputs(
2995 spec: &WasmBuildSpec,
2996 resolved: &ResolvedCargoBuildInputs,
2997 context: &WasmAcquisitionContext,
2998 progress: &mut ProgressReporter<'_>,
2999) -> Result<WasmInputResolutionTimings, WasmBuildError> {
3000 let mut timings = resolved.timings;
3001 if context.prepared_invalidation.is_none() {
3002 let verified = verify_resolved_inputs(spec, resolved, progress)?;
3003 timings.include(verified.timings);
3004 }
3005 Ok(timings)
3006}
3007
3008fn verify_resolved_inputs(
3009 spec: &WasmBuildSpec,
3010 resolved: &ResolvedCargoBuildInputs,
3011 progress: &mut ProgressReporter<'_>,
3012) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3013 let verified = resolve_initial_inputs(spec, None, progress)?;
3014 for (before, after) in [
3015 (resolved.validation_digest, verified.validation_digest),
3016 (resolved.fingerprint, verified.fingerprint),
3017 ] {
3018 if before != after {
3019 return Err(WasmBuildError::InputsChangedDuringAcquisition { before, after });
3020 }
3021 }
3022 Ok(verified)
3023}
3024
3025#[expect(
3026 clippy::too_many_arguments,
3027 reason = "keep the caller's spawner separate from recipe, acquisition and progress state"
3028)]
3029fn build_wasm_cache_miss(
3030 spec: &WasmBuildSpec,
3031 resolved: &ResolvedCargoBuildInputs,
3032 lock_wait: Duration,
3033 shared_incremental: WasmAcquisitionContext,
3034 cargo_target_dir: &Path,
3035 total_started: Instant,
3036 progress: &mut ProgressReporter<'_>,
3037 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
3038) -> Result<WasmBuildOutcome, WasmBuildError> {
3039 let targets = &resolved.wasm_outputs()?.targets;
3040 let fingerprint = resolved.fingerprint;
3041 let mut input_resolution = resolved.timings;
3042 let cache_entry = cache_entry_directory(spec, fingerprint);
3043 let preparation_started = Instant::now();
3044 progress.begin_phase(WasmBuildPhase::ArtifactPublication);
3045 let preparation_result = prepare_exact_cache_entry(&cache_entry);
3046 progress.record_phase(
3047 WasmBuildPhase::ArtifactPublication,
3048 preparation_started.elapsed(),
3049 );
3050 let incomplete_directory = preparation_result?;
3051 let build_result = (|| {
3052 if matches!(
3053 spec.cache_mode,
3054 WasmBuildCacheMode::SharedIncremental { .. }
3055 ) {
3056 record_cache_entry_use(cargo_target_dir)?;
3057 }
3058 let build_started = Instant::now();
3059 progress.begin_phase(WasmBuildPhase::CargoBuild);
3060 let cargo_result = run_cargo_build(spec, cargo_target_dir, targets, progress, &mut spawn);
3061 let cargo_build = build_started.elapsed();
3062 progress.record_phase(WasmBuildPhase::CargoBuild, cargo_build);
3063 let built_artifacts = cargo_result?;
3064 let validation_started = Instant::now();
3065 progress.begin_phase(WasmBuildPhase::ArtifactPublication);
3066 let missing = missing_artifacts(&built_artifacts);
3067 progress.record_phase(
3068 WasmBuildPhase::ArtifactPublication,
3069 validation_started.elapsed(),
3070 );
3071 if !missing.is_empty() {
3072 return Err(WasmBuildError::MissingArtifacts { paths: missing });
3073 }
3074
3075 let verified = verify_resolved_inputs(spec, resolved, progress)?;
3076 input_resolution.include(verified.timings);
3077
3078 let publication_guard = shared_incremental.lock_prepared_publication()?;
3083
3084 let paths = artifact_publication_paths(spec, &cache_entry, targets);
3085 progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
3086 copy_wasm_artifacts(&built_artifacts, &paths)?;
3087 let artifacts = publish_artifact_stamps(paths, fingerprint)?;
3088 materialize_artifacts(&artifacts, fingerprint, false)?;
3089 record_cache_entry_use(&cache_entry)
3090 })?;
3091 drop(publication_guard);
3092
3093 Ok(WasmBuildOutcome::Built(complete_build_record(
3094 spec,
3095 BuildRecordInput {
3096 resolved,
3097 lock_wait,
3098 shared_incremental,
3099 input_resolution,
3100 cargo_build: Some(cargo_build),
3101 active_entry: &cache_entry,
3102 targets,
3103 },
3104 total_started,
3105 progress,
3106 )?))
3107 })();
3108 finish_fingerprint_build(build_result, incomplete_directory, progress)
3109}
3110
3111pub fn prune_wasm_build_cache(
3123 target_dir: &Path,
3124 policy: CacheRetentionLimits,
3125) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3126 let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
3127 ensure_cache_directory_tag(target_dir)?;
3128
3129 prune_wasm_build_cache_locked(target_dir, policy, None)
3130}
3131
3132struct BuildRecordInput<'a> {
3133 resolved: &'a ResolvedCargoBuildInputs,
3134 lock_wait: Duration,
3135 shared_incremental: WasmAcquisitionContext,
3136 input_resolution: WasmInputResolutionTimings,
3137 cargo_build: Option<Duration>,
3138 active_entry: &'a Path,
3139 targets: &'a [WasmArtifactTarget],
3140}
3141
3142fn complete_build_record(
3143 spec: &WasmBuildSpec,
3144 input: BuildRecordInput<'_>,
3145 total_started: Instant,
3146 progress: &mut ProgressReporter<'_>,
3147) -> Result<WasmBuildRecord, WasmBuildError> {
3148 let retention = progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
3149 RetainedCacheEntry::acquire(input.active_entry).map_err(wasm_cache_fs_error)
3150 })?;
3151 let (maintenance, cache_maintenance) =
3152 spec.pruning.map_or((None, None), |(policy, interval)| {
3153 progress.run_phase(WasmBuildPhase::ExactCacheMaintenance, || {
3154 let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
3155 let identity = policy.maintenance_identity();
3156 perform_scheduled_cache_maintenance(&cache_root, interval, &identity, || {
3157 prune_wasm_build_cache_locked(
3158 &spec.target_dir,
3159 policy,
3160 Some(input.active_entry),
3161 )
3162 .map_err(|error| error.to_string())
3163 })
3164 })
3165 });
3166 Ok(WasmBuildRecord {
3167 facts: input.resolved.facts.clone(),
3168 fingerprint: input.resolved.fingerprint,
3169 recipe_identity: input.resolved.recipe_identity,
3170 input_digest: input.resolved.input_digest,
3171 artifacts: expected_artifacts(spec, input.active_entry, input.targets).into(),
3172 retention,
3173 timings: WasmBuildTimings {
3174 lock_wait: input.lock_wait,
3175 shared_incremental_lock_wait: input.shared_incremental.lock_wait,
3176 input_resolution: input.input_resolution,
3177 cargo_build: input.cargo_build,
3178 cache_maintenance,
3179 total: total_started.elapsed(),
3180 },
3181 maintenance,
3182 shared_incremental_maintenance: input.shared_incremental.maintenance,
3183 })
3184}
3185
3186fn prune_wasm_build_cache_locked(
3187 target_dir: &Path,
3188 policy: CacheRetentionLimits,
3189 protected_entry: Option<&Path>,
3190) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3191 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
3192 prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
3193 .map_err(wasm_cache_fs_error)
3194}
3195
3196fn finish_fingerprint_build<T>(
3197 result: Result<T, WasmBuildError>,
3198 incomplete_directory: OwnedDirectory,
3199 progress: &mut ProgressReporter<'_>,
3200) -> Result<T, WasmBuildError> {
3201 match result {
3202 Ok(outcome) => {
3203 incomplete_directory.preserve();
3204 Ok(outcome)
3205 }
3206 Err(build_error) => Err(cleanup_failed_fingerprint_build(
3207 build_error,
3208 incomplete_directory,
3209 progress,
3210 )),
3211 }
3212}
3213
3214fn cleanup_failed_fingerprint_build(
3215 build_error: WasmBuildError,
3216 incomplete_directory: OwnedDirectory,
3217 progress: &mut ProgressReporter<'_>,
3218) -> WasmBuildError {
3219 let path = incomplete_directory.path().to_owned();
3220 let primary_phase = progress.failure_phase;
3221 let cleanup_started = Instant::now();
3222 let cleanup = incomplete_directory.cleanup();
3223 progress.record_phase(WasmBuildPhase::Cleanup, cleanup_started.elapsed());
3224 match cleanup {
3225 Ok(()) => {
3226 progress.failure_phase = primary_phase;
3227 build_error
3228 }
3229 Err(source) => WasmBuildError::FailedBuildCleanup {
3230 build_error: Box::new(build_error),
3231 path,
3232 source,
3233 },
3234 }
3235}
3236
3237fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
3238 create_dir_all(target_dir, "create Cargo target directory")?;
3239 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3240 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)
3241}
3242
3243fn lock_wasm_build_cache_with_progress(
3244 target_dir: &Path,
3245 progress: &mut ProgressReporter<'_>,
3246) -> Result<(File, Duration), WasmBuildError> {
3247 progress.begin_phase(WasmBuildPhase::ExactCacheCoordination);
3248 create_dir_all(target_dir, "create Cargo target directory")?;
3249 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3250 lock_cache_file_with_progress(&lock_path, WasmBuildPhase::ExactCacheCoordination, progress)
3251}
3252
3253fn lock_shared_incremental_target(
3254 spec: &WasmBuildSpec,
3255) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3256 lock_shared_incremental_target_internal(spec, &mut ProgressReporter::silent())
3257}
3258
3259fn lock_shared_incremental_target_with_progress(
3260 spec: &WasmBuildSpec,
3261 progress: &mut ProgressReporter<'_>,
3262) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3263 let target_dir = shared_incremental_target(spec)
3264 .expect("validated shared acquisition must have a shared Cargo target");
3265 progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted { target_dir });
3266 let (lock, wait, canonical) = lock_shared_incremental_target_internal(spec, progress)?;
3267 progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
3268 target_dir: canonical.clone(),
3269 wait,
3270 });
3271 Ok((lock, wait, canonical))
3272}
3273
3274fn lock_shared_incremental_target_internal(
3275 spec: &WasmBuildSpec,
3276 progress: &mut ProgressReporter<'_>,
3277) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3278 progress.begin_phase(WasmBuildPhase::SharedTargetCoordination);
3279 let target_dir =
3280 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
3281 message: "shared incremental target is not configured".to_owned(),
3282 })?;
3283 create_dir_all(
3284 &target_dir,
3285 "create shared incremental Cargo target directory",
3286 )?;
3287 ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
3288 let canonical = target_dir
3289 .canonicalize()
3290 .map_err(|source| WasmBuildError::Io {
3291 operation: "resolve shared incremental Cargo target directory",
3292 path: target_dir.clone(),
3293 source,
3294 })?;
3295 let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
3296 let (lock, wait) = lock_cache_file_with_progress(
3297 &lock_path,
3298 WasmBuildPhase::SharedTargetCoordination,
3299 progress,
3300 )?;
3301 Ok((lock, wait, canonical))
3302}
3303
3304fn lock_cache_file_with_progress(
3305 lock_path: &Path,
3306 phase: WasmBuildPhase,
3307 progress: &mut ProgressReporter<'_>,
3308) -> Result<(File, Duration), WasmBuildError> {
3309 let started = Instant::now();
3310 progress.begin_phase(phase);
3311 let result = if !progress.is_observed() || progress.config.heartbeat_interval.is_none() {
3312 lock_cache_file(lock_path).map_err(wasm_cache_fs_error)
3313 } else {
3314 let heartbeat_interval = progress
3315 .config
3316 .heartbeat_interval
3317 .expect("observed cache lock must have a heartbeat interval");
3318 lock_cache_file_with_wait_observer(lock_path, heartbeat_interval, |elapsed| {
3319 progress.emit_heartbeat_if_due(phase, elapsed);
3320 })
3321 .map_err(wasm_cache_fs_error)
3322 };
3323 progress.record_phase(phase, started.elapsed());
3324 result
3325}
3326
3327fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
3328 ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
3329}
3330
3331fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
3332 record_entry_use(path).map_err(wasm_cache_fs_error)
3333}
3334
3335fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
3336 WasmBuildError::Io {
3337 operation: error.operation,
3338 path: error.path,
3339 source: error.source,
3340 }
3341}
3342
3343fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3344 for (key, value) in &spec.environment {
3345 let key = key.as_encoded_bytes();
3346 if key.is_empty()
3347 || key.iter().any(|byte| matches!(byte, b'=' | 0))
3348 || value.as_encoded_bytes().contains(&0)
3349 {
3350 return Err(WasmBuildError::InvalidSpec {
3351 message: "invalid child environment name or value".to_owned(),
3352 });
3353 }
3354 }
3355 if spec.cargo_message_bytes == 0 {
3356 return Err(WasmBuildError::InvalidSpec {
3357 message: "Cargo message allowance must be nonzero".to_owned(),
3358 });
3359 }
3360 if spec.packages.is_empty() {
3361 return Err(WasmBuildError::InvalidSpec {
3362 message: "at least one Cargo package is required".to_owned(),
3363 });
3364 }
3365 if spec.target.is_empty() {
3366 return Err(WasmBuildError::InvalidSpec {
3367 message: "Cargo compilation target must not be empty".to_owned(),
3368 });
3369 }
3370 if spec
3371 .environment
3372 .contains_key(OsStr::new("CARGO_TARGET_DIR"))
3373 {
3374 return Err(WasmBuildError::InvalidSpec {
3375 message: "Cargo target directories are owned by the build specification; use target_dir or with_shared_incremental_target instead of command overrides".to_owned(),
3376 });
3377 }
3378 validate_cargo_target_selection(spec)?;
3379 spec.cargo_selection
3380 .profile_target_dir()
3381 .map_err(|message| WasmBuildError::InvalidSpec {
3382 message: message.to_owned(),
3383 })?;
3384 if matches!(
3385 &spec.cache_mode,
3386 WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
3387 ) {
3388 return Err(WasmBuildError::InvalidSpec {
3389 message: "shared incremental Cargo target directory must not be empty".to_owned(),
3390 });
3391 }
3392 if spec.shared_incremental_maintenance_config.is_some()
3393 && !matches!(
3394 spec.cache_mode,
3395 WasmBuildCacheMode::SharedIncremental { .. }
3396 )
3397 {
3398 return Err(WasmBuildError::InvalidSpec {
3399 message:
3400 "scheduled shared-target maintenance requires a shared incremental Cargo target"
3401 .to_owned(),
3402 });
3403 }
3404 Ok(())
3405}
3406
3407fn validate_cargo_target_selection(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3408 if let Some(example) = &spec.example
3409 && (spec.packages.len() != 1 || !is_normal_component(Path::new(example)))
3410 {
3411 return Err(WasmBuildError::InvalidSpec {
3412 message: "a named Cargo example requires one package, a normal target name".to_owned(),
3413 });
3414 }
3415 if spec
3416 .packages
3417 .iter()
3418 .any(|package| !is_normal_component(Path::new(package)))
3419 {
3420 return Err(WasmBuildError::InvalidSpec {
3421 message: "Cargo package names must be normal path components".to_owned(),
3422 });
3423 }
3424 Ok(())
3425}
3426
3427fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3428 build_fingerprint_with_progress(spec, &mut ProgressReporter::silent())
3429}
3430
3431fn build_fingerprint_with_progress(
3432 spec: &WasmBuildSpec,
3433 progress: &mut ProgressReporter<'_>,
3434) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3435 build_fingerprint_with_policy(spec, progress, &mut None)
3436}
3437
3438fn build_fingerprint_with_policy(
3439 spec: &WasmBuildSpec,
3440 progress: &mut ProgressReporter<'_>,
3441 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3442) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3443 let total_started = Instant::now();
3444 let (facts, metadata, mut timings) = observe_cargo_resolution(spec, progress, policy)?;
3445
3446 let discovery_started = Instant::now();
3447 let (inputs, mut exclusions) = progress.run_phase(WasmBuildPhase::InputDiscovery, || {
3448 let parsed =
3449 ParsedCargoMetadata::parse(&metadata).map_err(|message| invalid_metadata(&message))?;
3450 resolve_local_inputs(spec, &parsed)
3451 })?;
3452 let input_discovery = discovery_started.elapsed();
3453
3454 let hashing_started = Instant::now();
3455 let (input_digest, validation_digest) =
3456 progress.run_phase(WasmBuildPhase::ContentHashing, || {
3457 let mut cache = LabeledPathDigestCache::default();
3458 digest_resolved_local_inputs(
3459 &inputs,
3460 &mut exclusions,
3461 &mut cache,
3462 &spec.workspace_root,
3463 "hash Wasm build inputs",
3464 "hash semantic Wasm build inputs",
3465 )
3466 })?;
3467 let content_hashing = hashing_started.elapsed();
3468
3469 timings.input_discovery = input_discovery;
3470 timings.content_hashing = content_hashing;
3471
3472 Ok(ResolvedCargoBuildInputs::assemble(
3473 spec,
3474 inputs,
3475 exclusions,
3476 input_digest,
3477 validation_digest,
3478 facts,
3479 || {
3480 timings.total = total_started.elapsed();
3481 timings
3482 },
3483 ))
3484}
3485
3486fn cargo_build_inputs(inputs: Vec<(PathBuf, PathBuf)>) -> Arc<[CargoBuildInput]> {
3487 inputs
3488 .into_iter()
3489 .map(|(label, path)| CargoBuildInput { label, path })
3490 .collect()
3491}
3492
3493fn finish_build_fingerprint(
3494 spec: &WasmBuildSpec,
3495 facts: &CargoBuildFacts,
3496 input_digest: InputDigest,
3497) -> InputDigest {
3498 let mut hasher = InputHasher::new(BUILD_FINGERPRINT_DOMAIN);
3499 for package in &spec.packages {
3500 hasher.field("package", package.as_bytes());
3501 }
3502 hasher.field("target", facts.target.as_bytes());
3503 if let Some(example) = &spec.example {
3504 hasher.field("cargo-target-selection", b"example");
3505 hasher.field("cargo-example", example.as_bytes());
3506 } else {
3507 hasher.field("cargo-target-selection", b"lib");
3508 }
3509 hasher.field(
3510 "profile-target-dir",
3511 facts
3512 .selection
3513 .profile_target_dir()
3514 .expect("Cargo profile was validated before fingerprinting")
3515 .as_bytes(),
3516 );
3517 for argument in &facts.selection.build_arguments() {
3518 hasher.field("cargo-argument", &os_bytes(argument));
3519 }
3520 for (key, value) in &facts.environment {
3521 hasher.field("environment-key", &os_bytes(key));
3522 hasher.field("environment-value", &os_bytes(value));
3523 }
3524 hasher.field("cargo-identity", &facts.cargo_identity);
3525 hasher.field("rustc-identity", &facts.rustc_identity);
3526 hasher.field("source-input-digest", input_digest.as_bytes());
3527 hasher.finish()
3528}
3529
3530fn cargo_metadata_with_policy(
3531 spec: &WasmBuildSpec,
3532 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3533) -> Result<schema::Metadata, WasmBuildError> {
3534 let mut command = Command::new(&spec.cargo_program);
3535 command
3536 .current_dir(&spec.workspace_root)
3537 .args(metadata_arguments(true, None));
3538 for argument in spec.cargo_selection.metadata_arguments() {
3539 command.arg(argument);
3540 }
3541 apply_command_environment(&mut command, spec);
3542 let output = capture_probe_output_with_policy(
3543 &mut command,
3544 WasmBuildPhase::CargoMetadata,
3545 CARGO_METADATA_BYTES,
3546 policy,
3547 )?;
3548 let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
3549 crate::cargo::observation::decode_metadata(&output.stdout).map_err(|error| {
3550 WasmBuildError::InvalidMetadata {
3551 message: format!("Cargo metadata did not match its schema: {error}"),
3552 }
3553 })
3554}
3555
3556fn capture_probe_output_with_policy(
3557 command: &mut Command,
3558 phase: WasmBuildPhase,
3559 stdout_bytes: usize,
3560 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3561) -> Result<CompletedExecution, WasmBuildError> {
3562 let limits = OutputLimits {
3563 stdout: OutputLimit::Terminate(stdout_bytes),
3564 stderr: OutputLimit::Terminate(TOOL_IDENTITY_BYTES),
3565 timeout: None,
3566 };
3567 let result = if let Some(policy) = policy {
3568 capture_group_command_with_cancellation(
3569 command,
3570 limits,
3571 policy.cleanup,
3572 policy.deadline,
3573 &mut *policy.cancelled,
3574 )
3575 } else {
3576 capture_command(command, limits)
3577 }
3578 .and_then(CompletedExecution::require_complete);
3579 let evidence = match result {
3580 Ok(evidence) => evidence,
3581 Err(ToolError::Execution(error))
3582 if matches!(
3583 error.failure,
3584 ExecutionFailure::Io {
3585 operation: ExecutionOperation::Spawn,
3586 ..
3587 }
3588 ) && error.cleanup.is_none() =>
3589 {
3590 let ExecutionFailure::Io { source, .. } = error.failure else {
3591 unreachable!()
3592 };
3593 return Err(WasmBuildError::CommandSpawn {
3594 phase,
3595 program: command.get_program().to_owned(),
3596 source,
3597 });
3598 }
3599 Err(error) => {
3600 return Err(WasmBuildError::Io {
3601 operation: match phase {
3602 WasmBuildPhase::CargoIdentity => "capture complete Cargo identity probe output",
3603 WasmBuildPhase::RustcIdentity => "capture complete rustc identity probe output",
3604 _ => "capture complete Cargo metadata probe output",
3605 },
3606 path: PathBuf::from(command.get_program()),
3607 source: io::Error::other(error),
3608 });
3609 }
3610 };
3611 Ok(evidence)
3612}
3613
3614pub(super) fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
3615 let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
3616 return None;
3617 };
3618 Some(if target_dir.is_absolute() {
3619 target_dir.clone()
3620 } else {
3621 spec.workspace_root.join(target_dir)
3622 })
3623}
3624
3625fn shared_incremental_target_exists(
3626 spec: &WasmBuildSpec,
3627 operation: &'static str,
3628) -> Result<bool, WasmBuildError> {
3629 let target_dir =
3630 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
3631 message: "shared incremental target is not configured".to_owned(),
3632 })?;
3633 match fs::symlink_metadata(&target_dir) {
3634 Ok(metadata) if metadata.is_dir() => Ok(true),
3635 Ok(_) => Err(WasmBuildError::InvalidSpec {
3636 message: format!(
3637 "shared incremental Cargo target {} must be a directory",
3638 target_dir.display()
3639 ),
3640 }),
3641 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
3642 Err(source) => Err(WasmBuildError::Io {
3643 operation,
3644 path: target_dir,
3645 source,
3646 }),
3647 }
3648}
3649
3650fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
3651 command.env_clear().envs(&spec.environment);
3652}
3653
3654fn run_cargo_build(
3655 spec: &WasmBuildSpec,
3656 build_target_dir: &Path,
3657 targets: &[WasmArtifactTarget],
3658 progress: &mut ProgressReporter<'_>,
3659 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
3660) -> Result<Vec<PathBuf>, WasmBuildError> {
3661 let command = cargo_build_command(spec, build_target_dir)?;
3662 communicate_cargo_build(
3663 spec,
3664 build_target_dir,
3665 targets,
3666 command,
3667 progress,
3668 &mut spawn,
3669 )
3670}
3671
3672impl WasmBuildSpec {
3673 fn cargo_invocation(&self) -> CargoInvocation {
3674 CargoInvocation {
3675 cargo_program: self.cargo_program.clone(),
3676 working_directory: self.workspace_root.clone(),
3677 environment: self.environment.clone(),
3678 target: self.target.clone(),
3679 selection: self.cargo_selection.clone(),
3680 packages: self.packages.clone(),
3681 manifest_path: None,
3682 recipe: self.example.as_ref().map_or(CargoRecipe::Library, |name| {
3683 CargoRecipe::Example(name.clone())
3684 }),
3685 configurations: Vec::new(),
3686 render_diagnostics: false,
3687 keep_going: false,
3688 }
3689 }
3690}
3691
3692fn cargo_build_command(
3693 spec: &WasmBuildSpec,
3694 build_target_dir: &Path,
3695) -> Result<Command, WasmBuildError> {
3696 let absolute_target_dir =
3697 canonicalize_allow_missing(build_target_dir).map_err(|source| WasmBuildError::Io {
3698 operation: "resolve Cargo build target directory",
3699 path: build_target_dir.to_owned(),
3700 source,
3701 })?;
3702 let mut invocation = spec.cargo_invocation();
3703 invocation.environment.insert(
3704 "CARGO_TARGET_DIR".into(),
3705 absolute_target_dir.into_os_string(),
3706 );
3707 invocation.command().map_err(|source| WasmBuildError::Io {
3708 operation: "render Cargo build invocation",
3709 path: spec.workspace_root.clone(),
3710 source,
3711 })
3712}
3713
3714fn communicate_cargo_build(
3715 spec: &WasmBuildSpec,
3716 build_target_dir: &Path,
3717 targets: &[WasmArtifactTarget],
3718 mut command: Command,
3719 progress: &mut ProgressReporter<'_>,
3720 spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
3721) -> Result<Vec<PathBuf>, WasmBuildError> {
3722 command
3725 .stdin(Stdio::null())
3726 .stdout(Stdio::piped())
3727 .stderr(Stdio::piped());
3728 let started = Instant::now();
3729 let mut child = spawn(&mut command).map_err(|source| WasmBuildError::CommandSpawn {
3730 phase: WasmBuildPhase::CargoBuild,
3731 program: spec.cargo_program.clone(),
3732 source,
3733 })?;
3734 progress.emit(WasmBuildProgressEvent::CargoStarted {
3735 target_dir: build_target_dir.to_owned(),
3736 });
3737 let progress = RefCell::new(progress);
3738 let mut artifacts = vec![None; targets.len()];
3739 let mut collect = |message| collect_wasm_artifact(message, targets, &mut artifacts);
3740 let observed = communicate_cargo(
3741 &mut child,
3742 OutputLimits {
3743 stdout: OutputLimit::Truncate(CARGO_DIAGNOSTIC_BYTES),
3746 stderr: OutputLimit::Truncate(CARGO_DIAGNOSTIC_BYTES),
3747 timeout: None,
3748 },
3749 spec.cargo_message_bytes,
3750 || {
3751 progress
3752 .borrow_mut()
3753 .emit_heartbeat_if_due(WasmBuildPhase::CargoBuild, started.elapsed());
3754 false
3755 },
3756 |stream, bytes| {
3757 let mut progress = progress.borrow_mut();
3758 if progress.config.emit_cargo_output {
3759 progress.emit(WasmBuildProgressEvent::CargoOutput {
3760 stream,
3761 bytes: bytes.to_vec(),
3762 });
3763 }
3764 Ok(())
3765 },
3766 &mut collect,
3767 );
3768 let result = observed.execution;
3769 let status = match &result {
3770 Ok(evidence) => Some(evidence.status),
3771 Err(error) => error.evidence().and_then(|evidence| evidence.status),
3772 };
3773 if let Some(status) = status {
3774 progress
3775 .borrow_mut()
3776 .emit(WasmBuildProgressEvent::CargoFinished {
3777 success: status.success(),
3778 code: status.code(),
3779 elapsed: started.elapsed(),
3780 });
3781 }
3782 let evidence = result.map_err(|error| WasmBuildError::Io {
3783 operation: "communicate with cargo build",
3784 path: PathBuf::from(&spec.cargo_program),
3785 source: io::Error::other(error),
3787 })?;
3788 let status = evidence.status;
3789 if status.success() {
3790 observed.messages.map_err(|source| WasmBuildError::Io {
3791 operation: "decode Cargo build artifacts",
3792 path: PathBuf::from(&spec.cargo_program),
3793 source,
3794 })?;
3795 return artifacts
3796 .into_iter()
3797 .collect::<Option<Vec<_>>>()
3798 .ok_or_else(|| WasmBuildError::MissingArtifacts {
3799 paths: expected_artifacts(spec, build_target_dir, targets),
3800 });
3801 }
3802 Err(WasmBuildError::CommandFailed {
3803 phase: WasmBuildPhase::CargoBuild,
3804 status,
3805 stdout: cargo_diagnostic(&evidence.stdout, evidence.stdout_truncated),
3806 stderr: cargo_diagnostic(&evidence.stderr, evidence.stderr_truncated),
3807 })
3808}
3809
3810fn collect_wasm_artifact(
3811 message: schema::Message,
3812 targets: &[WasmArtifactTarget],
3813 artifacts: &mut [Option<PathBuf>],
3814) -> io::Result<()> {
3815 let schema::Message::CompilerArtifact(artifact) = message else {
3816 return Ok(());
3817 };
3818 let Some((_, selected)) = targets.iter().zip(artifacts).find(|(target, _)| {
3820 artifact_matches(&artifact, &target.package_id, &target.name, &target.kind)
3821 }) else {
3822 return Ok(());
3823 };
3824 if !artifact
3825 .target
3826 .crate_types
3827 .contains(&schema::CrateType::CDyLib)
3828 {
3829 return Err(io::Error::new(
3830 io::ErrorKind::InvalidData,
3831 "Cargo reported a non-cdylib Wasm target",
3832 ));
3833 }
3834 let mut matching = wasm_artifact_paths(&artifact);
3835 let path = matching
3836 .next()
3837 .filter(|path| path.is_absolute())
3838 .ok_or_else(|| {
3839 io::Error::new(
3840 io::ErrorKind::InvalidData,
3841 "Cargo omitted absolute Wasm artifact path",
3842 )
3843 })?;
3844 if matching.next().is_some() || selected.replace(path.to_path_buf()).is_some() {
3845 return Err(io::Error::new(
3846 io::ErrorKind::InvalidData,
3847 "Cargo reported ambiguous Wasm artifacts",
3848 ));
3849 }
3850 Ok(())
3851}
3852
3853fn cargo_diagnostic(bytes: &[u8], truncated: bool) -> String {
3854 let mut text = String::from_utf8_lossy(bytes).into_owned();
3855 if truncated {
3856 text.push_str("\n[diagnostic truncated after 1 MiB; enable Cargo output events for the complete stream]\n");
3857 }
3858 text
3859}
3860
3861fn ensure_command_success(
3862 phase: WasmBuildPhase,
3863 output: CompletedExecution,
3864) -> Result<CompletedExecution, WasmBuildError> {
3865 if output.status.success() {
3866 return Ok(output);
3867 }
3868 Err(WasmBuildError::CommandFailed {
3869 phase,
3870 status: output.status,
3871 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
3872 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
3873 })
3874}
3875
3876fn artifact_relative_path(spec: &WasmBuildSpec, target: &WasmArtifactTarget) -> PathBuf {
3877 let profile = Path::new(&spec.target).join(
3878 spec.cargo_selection
3879 .profile_target_dir()
3880 .expect("Cargo profile was validated before artifact discovery"),
3881 );
3882 let directory = if spec.example.is_some() {
3883 profile.join("examples")
3884 } else {
3885 profile
3886 };
3887 directory.join(format!("{}.wasm", target.name.replace('-', "_")))
3888}
3889
3890fn expected_artifacts(
3891 spec: &WasmBuildSpec,
3892 target_dir: &Path,
3893 targets: &[WasmArtifactTarget],
3894) -> Vec<PathBuf> {
3895 targets
3896 .iter()
3897 .map(|target| target_dir.join(artifact_relative_path(spec, target)))
3898 .collect()
3899}
3900
3901fn artifact_publication_paths(
3902 spec: &WasmBuildSpec,
3903 cache_entry: &Path,
3904 targets: &[WasmArtifactTarget],
3905) -> Vec<(PathBuf, PathBuf)> {
3906 targets
3907 .iter()
3908 .map(|target| {
3909 let relative = artifact_relative_path(spec, target);
3910 (cache_entry.join(&relative), spec.target_dir.join(relative))
3911 })
3912 .collect()
3913}
3914
3915#[derive(Debug, Eq, PartialEq)]
3916struct VerifiedWasmArtifact {
3917 cached_path: PathBuf,
3918 public_path: PathBuf,
3919 info: FileDigest,
3920}
3921
3922fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
3923 spec.target_dir
3924 .join(".ic-testkit/wasm-targets")
3925 .join(fingerprint.to_hex())
3926}
3927
3928fn validated_artifact_set(
3929 artifacts: Vec<(PathBuf, PathBuf)>,
3930 fingerprint: InputDigest,
3931) -> Option<Vec<VerifiedWasmArtifact>> {
3932 let header = artifact_stamp_header(fingerprint);
3933 let stamp_len =
3935 header.len() + ARTIFACT_STAMP_DIGEST_LABEL.len() + fingerprint.as_bytes().len() * 2 + 1;
3936 artifacts
3937 .into_iter()
3938 .map(|(artifact, public_path)| {
3939 let metadata = fs::metadata(&artifact).ok()?;
3940 if !metadata.is_file() || metadata.len() == 0 {
3941 return None;
3942 }
3943 let stamp =
3944 read_stamp_with_limit(&artifact_stamp_path(&artifact), stamp_len).ok()??;
3945 if stamp.len() != stamp_len || !stamp.starts_with(&header) {
3948 return None;
3949 }
3950 let info = digest_file("wasm-artifact-v1", &artifact).ok()?;
3951 (stamp == artifact_stamp_contents(fingerprint, info.digest)).then_some(
3952 VerifiedWasmArtifact {
3953 cached_path: artifact,
3954 public_path,
3955 info,
3956 },
3957 )
3958 })
3959 .collect()
3960}
3961
3962fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
3963 artifacts
3964 .iter()
3965 .filter(|path| {
3966 fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
3967 })
3968 .cloned()
3969 .collect()
3970}
3971
3972fn artifact_stamp_path(artifact: &Path) -> PathBuf {
3973 let mut name = artifact
3974 .file_name()
3975 .map_or_else(|| OsString::from("artifact"), OsString::from);
3976 name.push(".ic-testkit-build");
3977 artifact.with_file_name(name)
3978}
3979
3980fn artifact_stamp_header(fingerprint: InputDigest) -> String {
3981 format!("{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\n")
3982}
3983
3984fn artifact_stamp_contents(fingerprint: InputDigest, artifact_digest: InputDigest) -> String {
3985 format!(
3986 "{}{ARTIFACT_STAMP_DIGEST_LABEL}{artifact_digest}\n",
3987 artifact_stamp_header(fingerprint),
3988 )
3989}
3990
3991fn write_artifact_stamp(
3992 artifact: &Path,
3993 fingerprint: InputDigest,
3994 info: &FileDigest,
3995 preserve_matching: bool,
3996) -> Result<(), WasmBuildError> {
3997 let stamp_path = artifact_stamp_path(artifact);
3998 let stamp = artifact_stamp_contents(fingerprint, info.digest);
3999 if preserve_matching && destination_matches_bytes(&stamp_path, stamp.as_bytes()) {
4000 return Ok(());
4001 }
4002 ic_host_fs::durable::write_bytes(
4003 &stamp_path,
4004 ic_host_fs::durable::WriteOptions {
4005 mode: ic_host_fs::durable::PublicationMode::Replace,
4006 permissions: 0o666,
4007 },
4008 stamp.as_bytes(),
4009 )
4010 .map_err(|source| WasmBuildError::Io {
4011 operation: "publish Wasm build stamp",
4012 path: stamp_path,
4013 source: io::Error::other(source),
4014 })
4015}
4016
4017fn publish_artifact_stamps(
4018 artifacts: Vec<(PathBuf, PathBuf)>,
4019 fingerprint: InputDigest,
4020) -> Result<Vec<VerifiedWasmArtifact>, WasmBuildError> {
4021 artifacts
4022 .into_iter()
4023 .map(|(cached_path, public_path)| {
4024 let info = digest_file("wasm-artifact-v1", &cached_path).map_err(|source| {
4025 WasmBuildError::Io {
4026 operation: "hash built Wasm artifact",
4027 path: cached_path.clone(),
4028 source,
4029 }
4030 })?;
4031 write_artifact_stamp(&cached_path, fingerprint, &info, false)?;
4032 Ok(VerifiedWasmArtifact {
4033 cached_path,
4034 public_path,
4035 info,
4036 })
4037 })
4038 .collect()
4039}
4040
4041fn materialize_artifacts(
4042 artifacts: &[VerifiedWasmArtifact],
4043 fingerprint: InputDigest,
4044 preserve_matching: bool,
4045) -> Result<(), WasmBuildError> {
4046 for artifact in artifacts {
4047 materialize_verified_output(
4048 "wasm-artifact-v1",
4049 &artifact.cached_path,
4050 &artifact.public_path,
4051 preserve_matching.then_some(&artifact.info),
4052 )
4053 .map_err(|source| WasmBuildError::Io {
4054 operation: "publish Wasm artifact",
4055 path: artifact.public_path.clone(),
4056 source,
4057 })?;
4058 }
4059 for artifact in artifacts {
4061 write_artifact_stamp(
4062 &artifact.public_path,
4063 fingerprint,
4064 &artifact.info,
4065 preserve_matching,
4066 )?;
4067 }
4068 Ok(())
4069}
4070
4071fn copy_wasm_artifacts(
4072 source_artifacts: &[PathBuf],
4073 destinations: &[(PathBuf, PathBuf)],
4074) -> Result<(), WasmBuildError> {
4075 if source_artifacts.len() != destinations.len() {
4077 return Err(invalid_metadata(
4078 "Cargo artifact count does not match admitted outputs",
4079 ));
4080 }
4081 for (index, (cached, _)) in destinations.iter().enumerate() {
4082 let source = &source_artifacts[index];
4083 if source != cached {
4084 copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
4085 operation: "cache shared-incremental Wasm artifact",
4086 path: cached.clone(),
4087 source: source_error,
4088 })?;
4089 }
4090 }
4091 Ok(())
4092}
4093
4094fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
4095 fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
4096 operation,
4097 path: path.to_owned(),
4098 source,
4099 })
4100}
4101
4102fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
4103 if path.exists() {
4104 inputs.push((PathBuf::from(label), path));
4105 }
4106}
4107
4108fn invalid_metadata(message: &str) -> WasmBuildError {
4109 WasmBuildError::InvalidMetadata {
4110 message: message.to_owned(),
4111 }
4112}
4113
4114impl WasmBuildError {
4115 fn indicates_input_change(&self) -> bool {
4116 match self {
4117 Self::InputsChangedDuringAcquisition { .. } => true,
4118 Self::FailedBuildCleanup { build_error, .. } => build_error.indicates_input_change(),
4119 _ => false,
4120 }
4121 }
4122}
4123
4124impl std::fmt::Display for WasmBuildError {
4125 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4126 match self {
4127 Self::InvalidSpec { message } => {
4128 write!(formatter, "invalid Wasm build spec: {message}")
4129 }
4130 Self::Io {
4131 operation,
4132 path,
4133 source,
4134 } => write!(
4135 formatter,
4136 "failed to {operation} at {}: {source}",
4137 path.display()
4138 ),
4139 Self::CommandSpawn {
4140 phase,
4141 program,
4142 source,
4143 } => write!(
4144 formatter,
4145 "failed to launch {phase} using `{}`: {source}",
4146 program.to_string_lossy(),
4147 ),
4148 Self::CommandFailed {
4149 phase,
4150 status,
4151 stdout,
4152 stderr,
4153 } => write!(
4154 formatter,
4155 "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
4156 ),
4157 Self::InvalidMetadata { message } => {
4158 write!(formatter, "invalid Cargo metadata: {message}")
4159 }
4160 Self::InvalidCargoConfiguration { path, message } => write!(
4161 formatter,
4162 "invalid Cargo configuration at {}: {message}",
4163 path.display(),
4164 ),
4165 Self::MissingArtifacts { paths } => {
4166 formatter.write_str("cargo build succeeded without producing: ")?;
4167 for (index, path) in paths.iter().enumerate() {
4168 if index != 0 {
4169 formatter.write_str(", ")?;
4170 }
4171 write!(formatter, "{}", path.display())?;
4172 }
4173 Ok(())
4174 }
4175 Self::InputsChangedDuringAcquisition { before, after } => write!(
4176 formatter,
4177 "Wasm inputs changed during artifact acquisition: {before} -> {after}",
4178 ),
4179 Self::PreparedInputSnapshotInvalidated => formatter.write_str(
4180 "the prepared Wasm input snapshot was invalidated before artifact publication",
4181 ),
4182 Self::FailedBuildCleanup {
4183 build_error,
4184 path,
4185 source,
4186 } => write!(
4187 formatter,
4188 "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
4189 path.display(),
4190 ),
4191 }
4192 }
4193}
4194
4195impl std::error::Error for WasmBuildError {
4196 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
4197 match self {
4198 Self::Io { source, .. }
4199 | Self::CommandSpawn { source, .. }
4200 | Self::FailedBuildCleanup { source, .. } => Some(source),
4201 _ => None,
4202 }
4203 }
4204}
4205
4206#[cfg(test)]
4207mod tests;