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 let outcome = 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 Ok(outcome)
2705 } else {
2706 progress.emit(WasmBuildProgressEvent::CacheMiss {
2707 fingerprint: resolved.fingerprint,
2708 });
2709
2710 match &spec.cache_mode {
2711 WasmBuildCacheMode::Isolated => {
2712 let cache_entry = cache_entry_directory(spec, resolved.fingerprint);
2713 build_wasm_cache_miss(
2714 spec,
2715 &resolved,
2716 first_lock_wait,
2717 isolated_acquisition,
2718 &cache_entry,
2719 total_started,
2720 progress,
2721 &mut spawn,
2722 )
2723 }
2724 WasmBuildCacheMode::SharedIncremental { .. } => {
2725 drop(cache_lock);
2726 build_wasm_with_shared_incremental(
2727 spec,
2728 Some((resolved, first_lock_wait)),
2729 isolated_acquisition.prepared_invalidation,
2730 total_started,
2731 progress,
2732 None,
2733 &mut spawn,
2734 )
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 let (outcome, elapsed) =
3154 progress.run_phase(WasmBuildPhase::ExactCacheMaintenance, || {
3155 let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
3156 let identity = policy.maintenance_identity();
3157 perform_scheduled_cache_maintenance(&cache_root, interval, &identity, || {
3158 prune_wasm_build_cache_locked(
3159 &spec.target_dir,
3160 policy,
3161 Some(input.active_entry),
3162 )
3163 .map_err(|error| error.to_string())
3164 })
3165 });
3166 (outcome, Some(elapsed))
3167 });
3168 Ok(WasmBuildRecord {
3169 facts: input.resolved.facts.clone(),
3170 fingerprint: input.resolved.fingerprint,
3171 recipe_identity: input.resolved.recipe_identity,
3172 input_digest: input.resolved.input_digest,
3173 artifacts: expected_artifacts(spec, input.active_entry, input.targets).into(),
3174 retention,
3175 timings: WasmBuildTimings {
3176 lock_wait: input.lock_wait,
3177 shared_incremental_lock_wait: input.shared_incremental.lock_wait,
3178 input_resolution: input.input_resolution,
3179 cargo_build: input.cargo_build,
3180 cache_maintenance,
3181 total: total_started.elapsed(),
3182 },
3183 maintenance,
3184 shared_incremental_maintenance: input.shared_incremental.maintenance,
3185 })
3186}
3187
3188fn prune_wasm_build_cache_locked(
3189 target_dir: &Path,
3190 policy: CacheRetentionLimits,
3191 protected_entry: Option<&Path>,
3192) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3193 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
3194 prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
3195 .map_err(wasm_cache_fs_error)
3196}
3197
3198fn finish_fingerprint_build<T>(
3199 result: Result<T, WasmBuildError>,
3200 incomplete_directory: OwnedDirectory,
3201 progress: &mut ProgressReporter<'_>,
3202) -> Result<T, WasmBuildError> {
3203 match result {
3204 Ok(outcome) => {
3205 incomplete_directory.preserve();
3206 Ok(outcome)
3207 }
3208 Err(build_error) => Err(cleanup_failed_fingerprint_build(
3209 build_error,
3210 incomplete_directory,
3211 progress,
3212 )),
3213 }
3214}
3215
3216fn cleanup_failed_fingerprint_build(
3217 build_error: WasmBuildError,
3218 incomplete_directory: OwnedDirectory,
3219 progress: &mut ProgressReporter<'_>,
3220) -> WasmBuildError {
3221 let path = incomplete_directory.path().to_owned();
3222 let primary_phase = progress.failure_phase;
3223 let cleanup_started = Instant::now();
3224 let cleanup = incomplete_directory.cleanup();
3225 progress.record_phase(WasmBuildPhase::Cleanup, cleanup_started.elapsed());
3226 match cleanup {
3227 Ok(()) => {
3228 progress.failure_phase = primary_phase;
3229 build_error
3230 }
3231 Err(source) => WasmBuildError::FailedBuildCleanup {
3232 build_error: Box::new(build_error),
3233 path,
3234 source,
3235 },
3236 }
3237}
3238
3239fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
3240 create_dir_all(target_dir, "create Cargo target directory")?;
3241 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3242 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)
3243}
3244
3245fn lock_wasm_build_cache_with_progress(
3246 target_dir: &Path,
3247 progress: &mut ProgressReporter<'_>,
3248) -> Result<(File, Duration), WasmBuildError> {
3249 progress.begin_phase(WasmBuildPhase::ExactCacheCoordination);
3250 create_dir_all(target_dir, "create Cargo target directory")?;
3251 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3252 lock_cache_file_with_progress(&lock_path, WasmBuildPhase::ExactCacheCoordination, progress)
3253}
3254
3255fn lock_shared_incremental_target(
3256 spec: &WasmBuildSpec,
3257) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3258 lock_shared_incremental_target_internal(spec, &mut ProgressReporter::silent())
3259}
3260
3261fn lock_shared_incremental_target_with_progress(
3262 spec: &WasmBuildSpec,
3263 progress: &mut ProgressReporter<'_>,
3264) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3265 let target_dir = shared_incremental_target(spec)
3266 .expect("validated shared acquisition must have a shared Cargo target");
3267 progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted { target_dir });
3268 let (lock, wait, canonical) = lock_shared_incremental_target_internal(spec, progress)?;
3269 progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
3270 target_dir: canonical.clone(),
3271 wait,
3272 });
3273 Ok((lock, wait, canonical))
3274}
3275
3276fn lock_shared_incremental_target_internal(
3277 spec: &WasmBuildSpec,
3278 progress: &mut ProgressReporter<'_>,
3279) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3280 progress.begin_phase(WasmBuildPhase::SharedTargetCoordination);
3281 let target_dir =
3282 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
3283 message: "shared incremental target is not configured".to_owned(),
3284 })?;
3285 create_dir_all(
3286 &target_dir,
3287 "create shared incremental Cargo target directory",
3288 )?;
3289 ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
3290 let canonical = target_dir
3291 .canonicalize()
3292 .map_err(|source| WasmBuildError::Io {
3293 operation: "resolve shared incremental Cargo target directory",
3294 path: target_dir.clone(),
3295 source,
3296 })?;
3297 let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
3298 let (lock, wait) = lock_cache_file_with_progress(
3299 &lock_path,
3300 WasmBuildPhase::SharedTargetCoordination,
3301 progress,
3302 )?;
3303 Ok((lock, wait, canonical))
3304}
3305
3306fn lock_cache_file_with_progress(
3307 lock_path: &Path,
3308 phase: WasmBuildPhase,
3309 progress: &mut ProgressReporter<'_>,
3310) -> Result<(File, Duration), WasmBuildError> {
3311 let started = Instant::now();
3312 progress.begin_phase(phase);
3313 let result = if !progress.is_observed() || progress.config.heartbeat_interval.is_none() {
3314 lock_cache_file(lock_path).map_err(wasm_cache_fs_error)
3315 } else {
3316 let heartbeat_interval = progress
3317 .config
3318 .heartbeat_interval
3319 .expect("observed cache lock must have a heartbeat interval");
3320 lock_cache_file_with_wait_observer(lock_path, heartbeat_interval, |elapsed| {
3321 progress.emit_heartbeat_if_due(phase, elapsed);
3322 })
3323 .map_err(wasm_cache_fs_error)
3324 };
3325 progress.record_phase(phase, started.elapsed());
3326 result
3327}
3328
3329fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
3330 ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
3331}
3332
3333fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
3334 record_entry_use(path).map_err(wasm_cache_fs_error)
3335}
3336
3337fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
3338 WasmBuildError::Io {
3339 operation: error.operation,
3340 path: error.path,
3341 source: error.source,
3342 }
3343}
3344
3345fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3346 for (key, value) in &spec.environment {
3347 let key = key.as_encoded_bytes();
3348 if key.is_empty()
3349 || key.iter().any(|byte| matches!(byte, b'=' | 0))
3350 || value.as_encoded_bytes().contains(&0)
3351 {
3352 return Err(WasmBuildError::InvalidSpec {
3353 message: "invalid child environment name or value".to_owned(),
3354 });
3355 }
3356 }
3357 if spec.cargo_message_bytes == 0 {
3358 return Err(WasmBuildError::InvalidSpec {
3359 message: "Cargo message allowance must be nonzero".to_owned(),
3360 });
3361 }
3362 if spec.packages.is_empty() {
3363 return Err(WasmBuildError::InvalidSpec {
3364 message: "at least one Cargo package is required".to_owned(),
3365 });
3366 }
3367 if spec.target.is_empty() {
3368 return Err(WasmBuildError::InvalidSpec {
3369 message: "Cargo compilation target must not be empty".to_owned(),
3370 });
3371 }
3372 if spec
3373 .environment
3374 .contains_key(OsStr::new("CARGO_TARGET_DIR"))
3375 {
3376 return Err(WasmBuildError::InvalidSpec {
3377 message: "Cargo target directories are owned by the build specification; use target_dir or with_shared_incremental_target instead of command overrides".to_owned(),
3378 });
3379 }
3380 validate_cargo_target_selection(spec)?;
3381 spec.cargo_selection
3382 .profile_target_dir()
3383 .map_err(|message| WasmBuildError::InvalidSpec {
3384 message: message.to_owned(),
3385 })?;
3386 if matches!(
3387 &spec.cache_mode,
3388 WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
3389 ) {
3390 return Err(WasmBuildError::InvalidSpec {
3391 message: "shared incremental Cargo target directory must not be empty".to_owned(),
3392 });
3393 }
3394 if spec.shared_incremental_maintenance_config.is_some()
3395 && !matches!(
3396 spec.cache_mode,
3397 WasmBuildCacheMode::SharedIncremental { .. }
3398 )
3399 {
3400 return Err(WasmBuildError::InvalidSpec {
3401 message:
3402 "scheduled shared-target maintenance requires a shared incremental Cargo target"
3403 .to_owned(),
3404 });
3405 }
3406 Ok(())
3407}
3408
3409fn validate_cargo_target_selection(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3410 if let Some(example) = &spec.example
3411 && (spec.packages.len() != 1 || !is_normal_component(Path::new(example)))
3412 {
3413 return Err(WasmBuildError::InvalidSpec {
3414 message: "a named Cargo example requires one package, a normal target name".to_owned(),
3415 });
3416 }
3417 if spec
3418 .packages
3419 .iter()
3420 .any(|package| !is_normal_component(Path::new(package)))
3421 {
3422 return Err(WasmBuildError::InvalidSpec {
3423 message: "Cargo package names must be normal path components".to_owned(),
3424 });
3425 }
3426 Ok(())
3427}
3428
3429fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3430 build_fingerprint_with_progress(spec, &mut ProgressReporter::silent())
3431}
3432
3433fn build_fingerprint_with_progress(
3434 spec: &WasmBuildSpec,
3435 progress: &mut ProgressReporter<'_>,
3436) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3437 build_fingerprint_with_policy(spec, progress, &mut None)
3438}
3439
3440fn build_fingerprint_with_policy(
3441 spec: &WasmBuildSpec,
3442 progress: &mut ProgressReporter<'_>,
3443 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3444) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3445 let total_started = Instant::now();
3446 let (facts, metadata, mut timings) = observe_cargo_resolution(spec, progress, policy)?;
3447
3448 let discovery_started = Instant::now();
3449 let (inputs, mut exclusions) = progress.run_phase(WasmBuildPhase::InputDiscovery, || {
3450 let parsed =
3451 ParsedCargoMetadata::parse(&metadata).map_err(|message| invalid_metadata(&message))?;
3452 resolve_local_inputs(spec, &parsed)
3453 })?;
3454 let input_discovery = discovery_started.elapsed();
3455
3456 let hashing_started = Instant::now();
3457 let (input_digest, validation_digest) =
3458 progress.run_phase(WasmBuildPhase::ContentHashing, || {
3459 let mut cache = LabeledPathDigestCache::default();
3460 digest_resolved_local_inputs(
3461 &inputs,
3462 &mut exclusions,
3463 &mut cache,
3464 &spec.workspace_root,
3465 "hash Wasm build inputs",
3466 "hash semantic Wasm build inputs",
3467 )
3468 })?;
3469 let content_hashing = hashing_started.elapsed();
3470
3471 timings.input_discovery = input_discovery;
3472 timings.content_hashing = content_hashing;
3473
3474 Ok(ResolvedCargoBuildInputs::assemble(
3475 spec,
3476 inputs,
3477 exclusions,
3478 input_digest,
3479 validation_digest,
3480 facts,
3481 || {
3482 timings.total = total_started.elapsed();
3483 timings
3484 },
3485 ))
3486}
3487
3488fn cargo_build_inputs(inputs: Vec<(PathBuf, PathBuf)>) -> Arc<[CargoBuildInput]> {
3489 inputs
3490 .into_iter()
3491 .map(|(label, path)| CargoBuildInput { label, path })
3492 .collect()
3493}
3494
3495fn finish_build_fingerprint(
3496 spec: &WasmBuildSpec,
3497 facts: &CargoBuildFacts,
3498 input_digest: InputDigest,
3499) -> InputDigest {
3500 let mut hasher = InputHasher::new(BUILD_FINGERPRINT_DOMAIN);
3501 for package in &spec.packages {
3502 hasher.field("package", package.as_bytes());
3503 }
3504 hasher.field("target", facts.target.as_bytes());
3505 if let Some(example) = &spec.example {
3506 hasher.field("cargo-target-selection", b"example");
3507 hasher.field("cargo-example", example.as_bytes());
3508 } else {
3509 hasher.field("cargo-target-selection", b"lib");
3510 }
3511 hasher.field(
3512 "profile-target-dir",
3513 facts
3514 .selection
3515 .profile_target_dir()
3516 .expect("Cargo profile was validated before fingerprinting")
3517 .as_bytes(),
3518 );
3519 for argument in &facts.selection.build_arguments() {
3520 hasher.field("cargo-argument", &os_bytes(argument));
3521 }
3522 for (key, value) in &facts.environment {
3523 hasher.field("environment-key", &os_bytes(key));
3524 hasher.field("environment-value", &os_bytes(value));
3525 }
3526 hasher.field("cargo-identity", &facts.cargo_identity);
3527 hasher.field("rustc-identity", &facts.rustc_identity);
3528 hasher.field("source-input-digest", input_digest.as_bytes());
3529 hasher.finish()
3530}
3531
3532fn cargo_metadata_with_policy(
3533 spec: &WasmBuildSpec,
3534 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3535) -> Result<schema::Metadata, WasmBuildError> {
3536 let mut command = Command::new(&spec.cargo_program);
3537 command
3538 .current_dir(&spec.workspace_root)
3539 .args(metadata_arguments(true, None));
3540 for argument in spec.cargo_selection.metadata_arguments() {
3541 command.arg(argument);
3542 }
3543 apply_command_environment(&mut command, spec);
3544 let output = capture_probe_output_with_policy(
3545 &mut command,
3546 WasmBuildPhase::CargoMetadata,
3547 CARGO_METADATA_BYTES,
3548 policy,
3549 )?;
3550 let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
3551 crate::cargo::observation::decode_metadata(&output.stdout).map_err(|error| {
3552 WasmBuildError::InvalidMetadata {
3553 message: format!("Cargo metadata did not match its schema: {error}"),
3554 }
3555 })
3556}
3557
3558fn capture_probe_output_with_policy(
3559 command: &mut Command,
3560 phase: WasmBuildPhase,
3561 stdout_bytes: usize,
3562 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3563) -> Result<CompletedExecution, WasmBuildError> {
3564 let limits = OutputLimits {
3565 stdout: OutputLimit::Terminate(stdout_bytes),
3566 stderr: OutputLimit::Terminate(TOOL_IDENTITY_BYTES),
3567 timeout: None,
3568 };
3569 let result = if let Some(policy) = policy {
3570 capture_group_command_with_cancellation(
3571 command,
3572 limits,
3573 policy.cleanup,
3574 policy.deadline,
3575 &mut *policy.cancelled,
3576 )
3577 } else {
3578 capture_command(command, limits)
3579 }
3580 .and_then(CompletedExecution::require_complete);
3581 let evidence = match result {
3582 Ok(evidence) => evidence,
3583 Err(ToolError::Execution(error))
3584 if matches!(
3585 error.failure,
3586 ExecutionFailure::Io {
3587 operation: ExecutionOperation::Spawn,
3588 ..
3589 }
3590 ) && error.cleanup.is_none() =>
3591 {
3592 let ExecutionFailure::Io { source, .. } = error.failure else {
3593 unreachable!()
3594 };
3595 return Err(WasmBuildError::CommandSpawn {
3596 phase,
3597 program: command.get_program().to_owned(),
3598 source,
3599 });
3600 }
3601 Err(error) => {
3602 return Err(WasmBuildError::Io {
3603 operation: match phase {
3604 WasmBuildPhase::CargoIdentity => "capture complete Cargo identity probe output",
3605 WasmBuildPhase::RustcIdentity => "capture complete rustc identity probe output",
3606 _ => "capture complete Cargo metadata probe output",
3607 },
3608 path: PathBuf::from(command.get_program()),
3609 source: io::Error::other(error),
3610 });
3611 }
3612 };
3613 Ok(evidence)
3614}
3615
3616pub(super) fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
3617 let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
3618 return None;
3619 };
3620 Some(if target_dir.is_absolute() {
3621 target_dir.clone()
3622 } else {
3623 spec.workspace_root.join(target_dir)
3624 })
3625}
3626
3627fn shared_incremental_target_exists(
3628 spec: &WasmBuildSpec,
3629 operation: &'static str,
3630) -> Result<bool, WasmBuildError> {
3631 let target_dir =
3632 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
3633 message: "shared incremental target is not configured".to_owned(),
3634 })?;
3635 match fs::symlink_metadata(&target_dir) {
3636 Ok(metadata) if metadata.is_dir() => Ok(true),
3637 Ok(_) => Err(WasmBuildError::InvalidSpec {
3638 message: format!(
3639 "shared incremental Cargo target {} must be a directory",
3640 target_dir.display()
3641 ),
3642 }),
3643 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
3644 Err(source) => Err(WasmBuildError::Io {
3645 operation,
3646 path: target_dir,
3647 source,
3648 }),
3649 }
3650}
3651
3652fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
3653 command.env_clear().envs(&spec.environment);
3654}
3655
3656fn run_cargo_build(
3657 spec: &WasmBuildSpec,
3658 build_target_dir: &Path,
3659 targets: &[WasmArtifactTarget],
3660 progress: &mut ProgressReporter<'_>,
3661 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
3662) -> Result<Vec<PathBuf>, WasmBuildError> {
3663 let command = cargo_build_command(spec, build_target_dir)?;
3664 communicate_cargo_build(
3665 spec,
3666 build_target_dir,
3667 targets,
3668 command,
3669 progress,
3670 &mut spawn,
3671 )
3672}
3673
3674impl WasmBuildSpec {
3675 fn cargo_invocation(&self) -> CargoInvocation {
3676 CargoInvocation {
3677 cargo_program: self.cargo_program.clone(),
3678 working_directory: self.workspace_root.clone(),
3679 environment: self.environment.clone(),
3680 target: self.target.clone(),
3681 selection: self.cargo_selection.clone(),
3682 packages: self.packages.clone(),
3683 manifest_path: None,
3684 recipe: self.example.as_ref().map_or(CargoRecipe::Library, |name| {
3685 CargoRecipe::Example(name.clone())
3686 }),
3687 configurations: Vec::new(),
3688 render_diagnostics: false,
3689 keep_going: false,
3690 }
3691 }
3692}
3693
3694fn cargo_build_command(
3695 spec: &WasmBuildSpec,
3696 build_target_dir: &Path,
3697) -> Result<Command, WasmBuildError> {
3698 let absolute_target_dir =
3699 canonicalize_allow_missing(build_target_dir).map_err(|source| WasmBuildError::Io {
3700 operation: "resolve Cargo build target directory",
3701 path: build_target_dir.to_owned(),
3702 source,
3703 })?;
3704 let mut invocation = spec.cargo_invocation();
3705 invocation.environment.insert(
3706 "CARGO_TARGET_DIR".into(),
3707 absolute_target_dir.into_os_string(),
3708 );
3709 invocation.command().map_err(|source| WasmBuildError::Io {
3710 operation: "render Cargo build invocation",
3711 path: spec.workspace_root.clone(),
3712 source,
3713 })
3714}
3715
3716fn communicate_cargo_build(
3717 spec: &WasmBuildSpec,
3718 build_target_dir: &Path,
3719 targets: &[WasmArtifactTarget],
3720 mut command: Command,
3721 progress: &mut ProgressReporter<'_>,
3722 spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
3723) -> Result<Vec<PathBuf>, WasmBuildError> {
3724 command
3727 .stdin(Stdio::null())
3728 .stdout(Stdio::piped())
3729 .stderr(Stdio::piped());
3730 let started = Instant::now();
3731 let mut child = spawn(&mut command).map_err(|source| WasmBuildError::CommandSpawn {
3732 phase: WasmBuildPhase::CargoBuild,
3733 program: spec.cargo_program.clone(),
3734 source,
3735 })?;
3736 progress.emit(WasmBuildProgressEvent::CargoStarted {
3737 target_dir: build_target_dir.to_owned(),
3738 });
3739 let progress = RefCell::new(progress);
3740 let mut artifacts = vec![None; targets.len()];
3741 let mut collect = |message| collect_wasm_artifact(message, targets, &mut artifacts);
3742 let observed = communicate_cargo(
3743 &mut child,
3744 OutputLimits {
3745 stdout: OutputLimit::Truncate(CARGO_DIAGNOSTIC_BYTES),
3748 stderr: OutputLimit::Truncate(CARGO_DIAGNOSTIC_BYTES),
3749 timeout: None,
3750 },
3751 spec.cargo_message_bytes,
3752 || {
3753 progress
3754 .borrow_mut()
3755 .emit_heartbeat_if_due(WasmBuildPhase::CargoBuild, started.elapsed());
3756 false
3757 },
3758 |stream, bytes| {
3759 let mut progress = progress.borrow_mut();
3760 if progress.config.emit_cargo_output {
3761 progress.emit(WasmBuildProgressEvent::CargoOutput {
3762 stream,
3763 bytes: bytes.to_vec(),
3764 });
3765 }
3766 Ok(())
3767 },
3768 &mut collect,
3769 );
3770 let result = observed.execution;
3771 let status = match &result {
3772 Ok(evidence) => Some(evidence.status),
3773 Err(error) => error.evidence().and_then(|evidence| evidence.status),
3774 };
3775 if let Some(status) = status {
3776 progress
3777 .borrow_mut()
3778 .emit(WasmBuildProgressEvent::CargoFinished {
3779 success: status.success(),
3780 code: status.code(),
3781 elapsed: started.elapsed(),
3782 });
3783 }
3784 let evidence = result.map_err(|error| WasmBuildError::Io {
3785 operation: "communicate with cargo build",
3786 path: PathBuf::from(&spec.cargo_program),
3787 source: io::Error::other(error),
3789 })?;
3790 let status = evidence.status;
3791 if status.success() {
3792 observed.messages.map_err(|source| WasmBuildError::Io {
3793 operation: "decode Cargo build artifacts",
3794 path: PathBuf::from(&spec.cargo_program),
3795 source,
3796 })?;
3797 return artifacts
3798 .into_iter()
3799 .collect::<Option<Vec<_>>>()
3800 .ok_or_else(|| WasmBuildError::MissingArtifacts {
3801 paths: expected_artifacts(spec, build_target_dir, targets),
3802 });
3803 }
3804 Err(WasmBuildError::CommandFailed {
3805 phase: WasmBuildPhase::CargoBuild,
3806 status,
3807 stdout: cargo_diagnostic(&evidence.stdout, evidence.stdout_truncated),
3808 stderr: cargo_diagnostic(&evidence.stderr, evidence.stderr_truncated),
3809 })
3810}
3811
3812fn collect_wasm_artifact(
3813 message: schema::Message,
3814 targets: &[WasmArtifactTarget],
3815 artifacts: &mut [Option<PathBuf>],
3816) -> io::Result<()> {
3817 let schema::Message::CompilerArtifact(artifact) = message else {
3818 return Ok(());
3819 };
3820 let Some((_, selected)) = targets.iter().zip(artifacts).find(|(target, _)| {
3822 artifact_matches(&artifact, &target.package_id, &target.name, &target.kind)
3823 }) else {
3824 return Ok(());
3825 };
3826 if !artifact
3827 .target
3828 .crate_types
3829 .contains(&schema::CrateType::CDyLib)
3830 {
3831 return Err(io::Error::new(
3832 io::ErrorKind::InvalidData,
3833 "Cargo reported a non-cdylib Wasm target",
3834 ));
3835 }
3836 let mut matching = wasm_artifact_paths(&artifact);
3837 let path = matching
3838 .next()
3839 .filter(|path| path.is_absolute())
3840 .ok_or_else(|| {
3841 io::Error::new(
3842 io::ErrorKind::InvalidData,
3843 "Cargo omitted absolute Wasm artifact path",
3844 )
3845 })?;
3846 if matching.next().is_some() || selected.replace(path.to_path_buf()).is_some() {
3847 return Err(io::Error::new(
3848 io::ErrorKind::InvalidData,
3849 "Cargo reported ambiguous Wasm artifacts",
3850 ));
3851 }
3852 Ok(())
3853}
3854
3855fn cargo_diagnostic(bytes: &[u8], truncated: bool) -> String {
3856 let mut text = String::from_utf8_lossy(bytes).into_owned();
3857 if truncated {
3858 text.push_str("\n[diagnostic truncated after 1 MiB; enable Cargo output events for the complete stream]\n");
3859 }
3860 text
3861}
3862
3863fn ensure_command_success(
3864 phase: WasmBuildPhase,
3865 output: CompletedExecution,
3866) -> Result<CompletedExecution, WasmBuildError> {
3867 if output.status.success() {
3868 return Ok(output);
3869 }
3870 Err(WasmBuildError::CommandFailed {
3871 phase,
3872 status: output.status,
3873 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
3874 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
3875 })
3876}
3877
3878fn artifact_relative_path(spec: &WasmBuildSpec, target: &WasmArtifactTarget) -> PathBuf {
3879 let profile = Path::new(&spec.target).join(
3880 spec.cargo_selection
3881 .profile_target_dir()
3882 .expect("Cargo profile was validated before artifact discovery"),
3883 );
3884 let directory = if spec.example.is_some() {
3885 profile.join("examples")
3886 } else {
3887 profile
3888 };
3889 directory.join(format!("{}.wasm", target.name.replace('-', "_")))
3890}
3891
3892fn expected_artifacts(
3893 spec: &WasmBuildSpec,
3894 target_dir: &Path,
3895 targets: &[WasmArtifactTarget],
3896) -> Vec<PathBuf> {
3897 targets
3898 .iter()
3899 .map(|target| target_dir.join(artifact_relative_path(spec, target)))
3900 .collect()
3901}
3902
3903fn artifact_publication_paths(
3904 spec: &WasmBuildSpec,
3905 cache_entry: &Path,
3906 targets: &[WasmArtifactTarget],
3907) -> Vec<(PathBuf, PathBuf)> {
3908 targets
3909 .iter()
3910 .map(|target| {
3911 let relative = artifact_relative_path(spec, target);
3912 (cache_entry.join(&relative), spec.target_dir.join(relative))
3913 })
3914 .collect()
3915}
3916
3917#[derive(Debug, Eq, PartialEq)]
3918struct VerifiedWasmArtifact {
3919 cached_path: PathBuf,
3920 public_path: PathBuf,
3921 info: FileDigest,
3922}
3923
3924fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
3925 spec.target_dir
3926 .join(".ic-testkit/wasm-targets")
3927 .join(fingerprint.to_hex())
3928}
3929
3930fn validated_artifact_set(
3931 artifacts: Vec<(PathBuf, PathBuf)>,
3932 fingerprint: InputDigest,
3933) -> Option<Vec<VerifiedWasmArtifact>> {
3934 let header = artifact_stamp_header(fingerprint);
3935 let stamp_len =
3937 header.len() + ARTIFACT_STAMP_DIGEST_LABEL.len() + fingerprint.as_bytes().len() * 2 + 1;
3938 artifacts
3939 .into_iter()
3940 .map(|(artifact, public_path)| {
3941 let metadata = fs::metadata(&artifact).ok()?;
3942 if !metadata.is_file() || metadata.len() == 0 {
3943 return None;
3944 }
3945 let stamp =
3946 read_stamp_with_limit(&artifact_stamp_path(&artifact), stamp_len).ok()??;
3947 if stamp.len() != stamp_len || !stamp.starts_with(&header) {
3950 return None;
3951 }
3952 let info = digest_file("wasm-artifact-v1", &artifact).ok()?;
3953 (stamp == artifact_stamp_contents(fingerprint, info.digest)).then_some(
3954 VerifiedWasmArtifact {
3955 cached_path: artifact,
3956 public_path,
3957 info,
3958 },
3959 )
3960 })
3961 .collect()
3962}
3963
3964fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
3965 artifacts
3966 .iter()
3967 .filter(|path| {
3968 fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
3969 })
3970 .cloned()
3971 .collect()
3972}
3973
3974fn artifact_stamp_path(artifact: &Path) -> PathBuf {
3975 let mut name = artifact
3976 .file_name()
3977 .map_or_else(|| OsString::from("artifact"), OsString::from);
3978 name.push(".ic-testkit-build");
3979 artifact.with_file_name(name)
3980}
3981
3982fn artifact_stamp_header(fingerprint: InputDigest) -> String {
3983 format!("{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\n")
3984}
3985
3986fn artifact_stamp_contents(fingerprint: InputDigest, artifact_digest: InputDigest) -> String {
3987 format!(
3988 "{}{ARTIFACT_STAMP_DIGEST_LABEL}{artifact_digest}\n",
3989 artifact_stamp_header(fingerprint),
3990 )
3991}
3992
3993fn write_artifact_stamp(
3994 artifact: &Path,
3995 fingerprint: InputDigest,
3996 info: &FileDigest,
3997 preserve_matching: bool,
3998) -> Result<(), WasmBuildError> {
3999 let stamp_path = artifact_stamp_path(artifact);
4000 let stamp = artifact_stamp_contents(fingerprint, info.digest);
4001 if preserve_matching && destination_matches_bytes(&stamp_path, stamp.as_bytes()) {
4002 return Ok(());
4003 }
4004 ic_host_fs::durable::write_bytes(
4005 &stamp_path,
4006 ic_host_fs::durable::WriteOptions {
4007 mode: ic_host_fs::durable::PublicationMode::Replace,
4008 permissions: 0o666,
4009 },
4010 stamp.as_bytes(),
4011 )
4012 .map_err(|source| WasmBuildError::Io {
4013 operation: "publish Wasm build stamp",
4014 path: stamp_path,
4015 source: io::Error::other(source),
4016 })
4017}
4018
4019fn publish_artifact_stamps(
4020 artifacts: Vec<(PathBuf, PathBuf)>,
4021 fingerprint: InputDigest,
4022) -> Result<Vec<VerifiedWasmArtifact>, WasmBuildError> {
4023 artifacts
4024 .into_iter()
4025 .map(|(cached_path, public_path)| {
4026 let info = digest_file("wasm-artifact-v1", &cached_path).map_err(|source| {
4027 WasmBuildError::Io {
4028 operation: "hash built Wasm artifact",
4029 path: cached_path.clone(),
4030 source,
4031 }
4032 })?;
4033 write_artifact_stamp(&cached_path, fingerprint, &info, false)?;
4034 Ok(VerifiedWasmArtifact {
4035 cached_path,
4036 public_path,
4037 info,
4038 })
4039 })
4040 .collect()
4041}
4042
4043fn materialize_artifacts(
4044 artifacts: &[VerifiedWasmArtifact],
4045 fingerprint: InputDigest,
4046 preserve_matching: bool,
4047) -> Result<(), WasmBuildError> {
4048 for artifact in artifacts {
4049 materialize_verified_output(
4050 "wasm-artifact-v1",
4051 &artifact.cached_path,
4052 &artifact.public_path,
4053 preserve_matching.then_some(&artifact.info),
4054 )
4055 .map_err(|source| WasmBuildError::Io {
4056 operation: "publish Wasm artifact",
4057 path: artifact.public_path.clone(),
4058 source,
4059 })?;
4060 }
4061 for artifact in artifacts {
4063 write_artifact_stamp(
4064 &artifact.public_path,
4065 fingerprint,
4066 &artifact.info,
4067 preserve_matching,
4068 )?;
4069 }
4070 Ok(())
4071}
4072
4073fn copy_wasm_artifacts(
4074 source_artifacts: &[PathBuf],
4075 destinations: &[(PathBuf, PathBuf)],
4076) -> Result<(), WasmBuildError> {
4077 if source_artifacts.len() != destinations.len() {
4079 return Err(invalid_metadata(
4080 "Cargo artifact count does not match admitted outputs",
4081 ));
4082 }
4083 for (index, (cached, _)) in destinations.iter().enumerate() {
4084 let source = &source_artifacts[index];
4085 if source != cached {
4086 copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
4087 operation: "cache shared-incremental Wasm artifact",
4088 path: cached.clone(),
4089 source: source_error,
4090 })?;
4091 }
4092 }
4093 Ok(())
4094}
4095
4096fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
4097 fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
4098 operation,
4099 path: path.to_owned(),
4100 source,
4101 })
4102}
4103
4104fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
4105 if path.exists() {
4106 inputs.push((PathBuf::from(label), path));
4107 }
4108}
4109
4110fn invalid_metadata(message: &str) -> WasmBuildError {
4111 WasmBuildError::InvalidMetadata {
4112 message: message.to_owned(),
4113 }
4114}
4115
4116impl WasmBuildError {
4117 fn indicates_input_change(&self) -> bool {
4118 match self {
4119 Self::InputsChangedDuringAcquisition { .. } => true,
4120 Self::FailedBuildCleanup { build_error, .. } => build_error.indicates_input_change(),
4121 _ => false,
4122 }
4123 }
4124}
4125
4126impl std::fmt::Display for WasmBuildError {
4127 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4128 match self {
4129 Self::InvalidSpec { message } => {
4130 write!(formatter, "invalid Wasm build spec: {message}")
4131 }
4132 Self::Io {
4133 operation,
4134 path,
4135 source,
4136 } => write!(
4137 formatter,
4138 "failed to {operation} at {}: {source}",
4139 path.display()
4140 ),
4141 Self::CommandSpawn {
4142 phase,
4143 program,
4144 source,
4145 } => write!(
4146 formatter,
4147 "failed to launch {phase} using `{}`: {source}",
4148 program.to_string_lossy(),
4149 ),
4150 Self::CommandFailed {
4151 phase,
4152 status,
4153 stdout,
4154 stderr,
4155 } => write!(
4156 formatter,
4157 "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
4158 ),
4159 Self::InvalidMetadata { message } => {
4160 write!(formatter, "invalid Cargo metadata: {message}")
4161 }
4162 Self::InvalidCargoConfiguration { path, message } => write!(
4163 formatter,
4164 "invalid Cargo configuration at {}: {message}",
4165 path.display(),
4166 ),
4167 Self::MissingArtifacts { paths } => {
4168 formatter.write_str("cargo build succeeded without producing: ")?;
4169 for (index, path) in paths.iter().enumerate() {
4170 if index != 0 {
4171 formatter.write_str(", ")?;
4172 }
4173 write!(formatter, "{}", path.display())?;
4174 }
4175 Ok(())
4176 }
4177 Self::InputsChangedDuringAcquisition { before, after } => write!(
4178 formatter,
4179 "Wasm inputs changed during artifact acquisition: {before} -> {after}",
4180 ),
4181 Self::PreparedInputSnapshotInvalidated => formatter.write_str(
4182 "the prepared Wasm input snapshot was invalidated before artifact publication",
4183 ),
4184 Self::FailedBuildCleanup {
4185 build_error,
4186 path,
4187 source,
4188 } => write!(
4189 formatter,
4190 "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
4191 path.display(),
4192 ),
4193 }
4194 }
4195}
4196
4197impl std::error::Error for WasmBuildError {
4198 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
4199 match self {
4200 Self::Io { source, .. }
4201 | Self::CommandSpawn { source, .. }
4202 | Self::FailedBuildCleanup { source, .. } => Some(source),
4203 _ => None,
4204 }
4205 }
4206}
4207
4208#[cfg(test)]
4209mod tests;