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