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