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_with_shared_incremental(
2670 spec,
2671 None,
2672 prepared_invalidation,
2673 total_started,
2674 progress,
2675 batch_resolution.take(),
2676 &mut spawn,
2677 )?;
2678 emit_finished_progress(&outcome, progress);
2679 return Ok(outcome);
2680 }
2681 let (cache_lock, first_lock_wait) =
2682 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2683 ensure_cache_directory_tag(&spec.target_dir)?;
2684
2685 let resolved = resolve_initial_inputs(spec, batch_resolution.take(), progress)?;
2686 let isolated_acquisition = WasmAcquisitionContext::isolated(prepared_invalidation);
2687 if let Some(outcome) = try_reuse_wasm_artifacts(
2688 spec,
2689 &resolved,
2690 first_lock_wait,
2691 &isolated_acquisition,
2692 total_started,
2693 progress,
2694 )? {
2695 emit_finished_progress(&outcome, progress);
2696 return Ok(outcome);
2697 }
2698 progress.emit(WasmBuildProgressEvent::CacheMiss {
2699 fingerprint: resolved.fingerprint,
2700 });
2701
2702 let outcome = match &spec.cache_mode {
2703 WasmBuildCacheMode::Isolated => {
2704 let cache_entry = cache_entry_directory(spec, resolved.fingerprint);
2705 build_wasm_cache_miss(
2706 spec,
2707 &resolved,
2708 first_lock_wait,
2709 isolated_acquisition,
2710 &cache_entry,
2711 total_started,
2712 progress,
2713 &mut spawn,
2714 )
2715 }
2716 WasmBuildCacheMode::SharedIncremental { .. } => {
2717 drop(cache_lock);
2718 build_wasm_with_shared_incremental(
2719 spec,
2720 Some((resolved, first_lock_wait)),
2721 isolated_acquisition.prepared_invalidation,
2722 total_started,
2723 progress,
2724 None,
2725 &mut spawn,
2726 )
2727 }
2728 }?;
2729 emit_finished_progress(&outcome, progress);
2730 Ok(outcome)
2731}
2732
2733fn build_wasm_with_shared_incremental(
2734 spec: &WasmBuildSpec,
2735 initial: Option<(ResolvedCargoBuildInputs, Duration)>,
2736 prepared_invalidation: Option<Arc<RwLock<bool>>>,
2737 total_started: Instant,
2738 progress: &mut ProgressReporter<'_>,
2739 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2740 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
2741) -> Result<WasmBuildOutcome, WasmBuildError> {
2742 let (shared_lock, shared_lock_wait, shared_target) =
2743 lock_shared_incremental_target_with_progress(spec, progress)?;
2744 let (_cache_lock, second_lock_wait) =
2745 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2746 ensure_cache_directory_tag(&spec.target_dir)?;
2747
2748 let emit_cache_miss = initial.is_none();
2749 let (current, first_lock_wait) = match initial {
2753 Some((resolved, wait)) if prepared_invalidation.is_some() => (resolved, wait),
2754 Some((resolved, wait)) => {
2755 let mut current = resolve_initial_inputs(spec, None, progress)?;
2756 current.timings.include(resolved.timings);
2757 (current, wait)
2758 }
2759 None => (
2760 resolve_initial_inputs(spec, batch_resolution, progress)?,
2761 Duration::ZERO,
2762 ),
2763 };
2764 let lock_wait = first_lock_wait.saturating_add(second_lock_wait);
2765 let maintenance = spec
2766 .shared_incremental_maintenance_config
2767 .map(|_| {
2768 perform_configured_shared_incremental_target_maintenance(
2769 spec,
2770 &shared_target,
2771 shared_lock_wait,
2772 progress,
2773 )
2774 })
2775 .transpose()?;
2776 let shared_incremental =
2777 WasmAcquisitionContext::shared(shared_lock_wait, maintenance, prepared_invalidation);
2778 if let Some(outcome) = try_reuse_wasm_artifacts(
2779 spec,
2780 ¤t,
2781 lock_wait,
2782 &shared_incremental,
2783 total_started,
2784 progress,
2785 )? {
2786 return Ok(outcome);
2787 }
2788 if emit_cache_miss {
2789 progress.emit(WasmBuildProgressEvent::CacheMiss {
2790 fingerprint: current.fingerprint,
2791 });
2792 }
2793
2794 let outcome = build_wasm_cache_miss(
2795 spec,
2796 ¤t,
2797 lock_wait,
2798 shared_incremental,
2799 &shared_target,
2800 total_started,
2801 progress,
2802 &mut spawn,
2803 );
2804 drop(shared_lock);
2805 outcome
2806}
2807
2808fn perform_configured_shared_incremental_target_maintenance(
2809 spec: &WasmBuildSpec,
2810 shared_target: &Path,
2811 lock_wait: Duration,
2812 progress: &mut ProgressReporter<'_>,
2813) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2814 let config = spec
2815 .shared_incremental_maintenance_config
2816 .expect("configured shared-target maintenance must have settings");
2817 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceStarted {
2818 target_dir: shared_target.to_owned(),
2819 });
2820 let result = progress.run_phase(WasmBuildPhase::SharedTargetMaintenance, || {
2821 let schedule = schedule_shared_incremental_target_maintenance(
2822 shared_target,
2823 config.policy,
2824 config.minimum_interval,
2825 lock_wait,
2826 )?;
2827 match schedule {
2828 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => Ok(outcome),
2829 SharedIncrementalTargetMaintenanceSchedule::Due(due) => {
2830 perform_due_shared_incremental_target_maintenance(
2831 shared_target,
2832 config.policy,
2833 lock_wait,
2834 due,
2835 )
2836 }
2837 }
2838 });
2839 let outcome = integrated_shared_maintenance_result(config, shared_target, lock_wait, result)?;
2840 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceFinished {
2841 outcome: outcome.clone(),
2842 });
2843 Ok(outcome)
2844}
2845
2846fn integrated_shared_maintenance_result(
2847 config: SharedIncrementalTargetMaintenanceConfig,
2848 shared_target: &Path,
2849 lock_wait: Duration,
2850 result: Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError>,
2851) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2852 match result {
2853 Ok(outcome) => Ok(outcome),
2854 Err(error)
2855 if config.failure_mode == SharedIncrementalTargetMaintenanceFailureMode::BestEffort =>
2856 {
2857 Ok(SharedIncrementalTargetMaintenanceOutcome::Failed {
2858 target_dir: shared_target.to_owned(),
2859 lock_wait,
2860 message: error.to_string(),
2861 })
2862 }
2863 Err(error) => Err(error),
2864 }
2865}
2866
2867fn resolve_initial_inputs(
2868 spec: &WasmBuildSpec,
2869 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2870 progress: &mut ProgressReporter<'_>,
2871) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2872 let resolved = if let Some((resolver, index)) = batch_resolution {
2873 resolver.resolve(index, progress)?
2874 } else {
2875 build_fingerprint_with_progress(spec, progress)?
2876 };
2877 resolved.wasm_outputs().inspect_err(|_| {
2878 progress.begin_phase(WasmBuildPhase::InputDiscovery);
2879 })?;
2880 progress.emit(WasmBuildProgressEvent::InputsResolved {
2881 fingerprint: resolved.fingerprint,
2882 input_digest: resolved.input_digest,
2883 elapsed: resolved.timings.total,
2884 });
2885 Ok(resolved)
2886}
2887
2888fn emit_finished_progress(outcome: &WasmBuildOutcome, progress: &mut ProgressReporter<'_>) {
2889 let state = if outcome.is_reused() {
2890 progress.emit(WasmBuildProgressEvent::CacheHit {
2891 fingerprint: outcome.record().fingerprint,
2892 });
2893 WasmBuildProgressOutcome::Reused
2894 } else {
2895 WasmBuildProgressOutcome::Built
2896 };
2897 progress.emit(WasmBuildProgressEvent::Finished {
2898 outcome: state,
2899 fingerprint: outcome.record().fingerprint,
2900 elapsed: outcome.record().timings.total,
2901 });
2902}
2903
2904#[derive(Clone, Debug, Default)]
2905struct WasmAcquisitionContext {
2906 lock_wait: Option<Duration>,
2907 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
2908 prepared_invalidation: Option<Arc<RwLock<bool>>>,
2909}
2910
2911impl WasmAcquisitionContext {
2912 fn isolated(prepared_invalidation: Option<Arc<RwLock<bool>>>) -> Self {
2913 Self {
2914 prepared_invalidation,
2915 ..Self::default()
2916 }
2917 }
2918
2919 const fn shared(
2920 lock_wait: Duration,
2921 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
2922 prepared_invalidation: Option<Arc<RwLock<bool>>>,
2923 ) -> Self {
2924 Self {
2925 lock_wait: Some(lock_wait),
2926 maintenance,
2927 prepared_invalidation,
2928 }
2929 }
2930
2931 fn lock_prepared_publication(
2932 &self,
2933 ) -> Result<Option<std::sync::RwLockReadGuard<'_, bool>>, WasmBuildError> {
2934 let guard = self.prepared_invalidation.as_deref().map(|invalidation| {
2935 invalidation
2936 .read()
2937 .unwrap_or_else(std::sync::PoisonError::into_inner)
2938 });
2939 if guard.as_deref().is_some_and(|invalidated| *invalidated) {
2940 return Err(WasmBuildError::PreparedInputSnapshotInvalidated);
2941 }
2942 Ok(guard)
2943 }
2944}
2945
2946fn try_reuse_wasm_artifacts(
2947 spec: &WasmBuildSpec,
2948 resolved: &ResolvedCargoBuildInputs,
2949 lock_wait: Duration,
2950 shared_incremental: &WasmAcquisitionContext,
2951 total_started: Instant,
2952 progress: &mut ProgressReporter<'_>,
2953) -> Result<Option<WasmBuildOutcome>, WasmBuildError> {
2954 let targets = &resolved.wasm_outputs()?.targets;
2955 let _publication_guard = shared_incremental.lock_prepared_publication()?;
2956 let fingerprint = resolved.fingerprint;
2957 let cache_entry = cache_entry_directory(spec, fingerprint);
2958 let paths = artifact_publication_paths(spec, &cache_entry, targets);
2959 let Some(artifact_info) = progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
2960 validated_artifact_set(paths, fingerprint)
2961 }) else {
2962 return Ok(None);
2963 };
2964 progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
2965 materialize_artifacts(&artifact_info, fingerprint, true)?;
2966 record_cache_entry_use(&cache_entry)
2967 })?;
2968 let input_resolution = validate_reused_inputs(spec, resolved, shared_incremental, progress)?;
2969 Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
2970 spec,
2971 BuildRecordInput {
2972 resolved,
2973 lock_wait,
2974 shared_incremental: shared_incremental.clone(),
2975 input_resolution,
2976 cargo_build: None,
2977 active_entry: &cache_entry,
2978 targets,
2979 },
2980 total_started,
2981 progress,
2982 )?)))
2983}
2984
2985fn validate_reused_inputs(
2986 spec: &WasmBuildSpec,
2987 resolved: &ResolvedCargoBuildInputs,
2988 context: &WasmAcquisitionContext,
2989 progress: &mut ProgressReporter<'_>,
2990) -> Result<WasmInputResolutionTimings, WasmBuildError> {
2991 let mut timings = resolved.timings;
2992 if context.prepared_invalidation.is_none() {
2993 let verified = verify_resolved_inputs(spec, resolved, progress)?;
2994 timings.include(verified.timings);
2995 }
2996 Ok(timings)
2997}
2998
2999fn verify_resolved_inputs(
3000 spec: &WasmBuildSpec,
3001 resolved: &ResolvedCargoBuildInputs,
3002 progress: &mut ProgressReporter<'_>,
3003) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3004 let verified = resolve_initial_inputs(spec, None, progress)?;
3005 for (before, after) in [
3006 (resolved.validation_digest, verified.validation_digest),
3007 (resolved.fingerprint, verified.fingerprint),
3008 ] {
3009 if before != after {
3010 return Err(WasmBuildError::InputsChangedDuringAcquisition { before, after });
3011 }
3012 }
3013 Ok(verified)
3014}
3015
3016#[expect(
3017 clippy::too_many_arguments,
3018 reason = "keep the caller's spawner separate from recipe, acquisition and progress state"
3019)]
3020fn build_wasm_cache_miss(
3021 spec: &WasmBuildSpec,
3022 resolved: &ResolvedCargoBuildInputs,
3023 lock_wait: Duration,
3024 shared_incremental: WasmAcquisitionContext,
3025 cargo_target_dir: &Path,
3026 total_started: Instant,
3027 progress: &mut ProgressReporter<'_>,
3028 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
3029) -> Result<WasmBuildOutcome, WasmBuildError> {
3030 let targets = &resolved.wasm_outputs()?.targets;
3031 let fingerprint = resolved.fingerprint;
3032 let mut input_resolution = resolved.timings;
3033 let cache_entry = cache_entry_directory(spec, fingerprint);
3034 let preparation_started = Instant::now();
3035 progress.begin_phase(WasmBuildPhase::ArtifactPublication);
3036 let preparation_result = prepare_exact_cache_entry(&cache_entry);
3037 progress.record_phase(
3038 WasmBuildPhase::ArtifactPublication,
3039 preparation_started.elapsed(),
3040 );
3041 let incomplete_directory = preparation_result?;
3042 let build_result = (|| {
3043 if matches!(
3044 spec.cache_mode,
3045 WasmBuildCacheMode::SharedIncremental { .. }
3046 ) {
3047 record_cache_entry_use(cargo_target_dir)?;
3048 }
3049 let build_started = Instant::now();
3050 progress.begin_phase(WasmBuildPhase::CargoBuild);
3051 let cargo_result = run_cargo_build(spec, cargo_target_dir, targets, progress, &mut spawn);
3052 let cargo_build = build_started.elapsed();
3053 progress.record_phase(WasmBuildPhase::CargoBuild, cargo_build);
3054 let built_artifacts = cargo_result?;
3055 let validation_started = Instant::now();
3056 progress.begin_phase(WasmBuildPhase::ArtifactPublication);
3057 let missing = missing_artifacts(&built_artifacts);
3058 progress.record_phase(
3059 WasmBuildPhase::ArtifactPublication,
3060 validation_started.elapsed(),
3061 );
3062 if !missing.is_empty() {
3063 return Err(WasmBuildError::MissingArtifacts { paths: missing });
3064 }
3065
3066 let verified = verify_resolved_inputs(spec, resolved, progress)?;
3067 input_resolution.include(verified.timings);
3068
3069 let publication_guard = shared_incremental.lock_prepared_publication()?;
3074
3075 let paths = artifact_publication_paths(spec, &cache_entry, targets);
3076 progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
3077 copy_wasm_artifacts(&built_artifacts, &paths)?;
3078 let artifacts = publish_artifact_stamps(paths, fingerprint)?;
3079 materialize_artifacts(&artifacts, fingerprint, false)?;
3080 record_cache_entry_use(&cache_entry)
3081 })?;
3082 drop(publication_guard);
3083
3084 Ok(WasmBuildOutcome::Built(complete_build_record(
3085 spec,
3086 BuildRecordInput {
3087 resolved,
3088 lock_wait,
3089 shared_incremental,
3090 input_resolution,
3091 cargo_build: Some(cargo_build),
3092 active_entry: &cache_entry,
3093 targets,
3094 },
3095 total_started,
3096 progress,
3097 )?))
3098 })();
3099 finish_fingerprint_build(build_result, incomplete_directory, progress)
3100}
3101
3102pub fn prune_wasm_build_cache(
3114 target_dir: &Path,
3115 policy: CacheRetentionLimits,
3116) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3117 let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
3118 ensure_cache_directory_tag(target_dir)?;
3119
3120 prune_wasm_build_cache_locked(target_dir, policy, None)
3121}
3122
3123struct BuildRecordInput<'a> {
3124 resolved: &'a ResolvedCargoBuildInputs,
3125 lock_wait: Duration,
3126 shared_incremental: WasmAcquisitionContext,
3127 input_resolution: WasmInputResolutionTimings,
3128 cargo_build: Option<Duration>,
3129 active_entry: &'a Path,
3130 targets: &'a [WasmArtifactTarget],
3131}
3132
3133fn complete_build_record(
3134 spec: &WasmBuildSpec,
3135 input: BuildRecordInput<'_>,
3136 total_started: Instant,
3137 progress: &mut ProgressReporter<'_>,
3138) -> Result<WasmBuildRecord, WasmBuildError> {
3139 let retention = progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
3140 RetainedCacheEntry::acquire(input.active_entry).map_err(wasm_cache_fs_error)
3141 })?;
3142 let (maintenance, cache_maintenance) =
3143 spec.pruning.map_or((None, None), |(policy, interval)| {
3144 progress.run_phase(WasmBuildPhase::ExactCacheMaintenance, || {
3145 let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
3146 let identity = policy.maintenance_identity();
3147 perform_scheduled_cache_maintenance(&cache_root, interval, &identity, || {
3148 prune_wasm_build_cache_locked(
3149 &spec.target_dir,
3150 policy,
3151 Some(input.active_entry),
3152 )
3153 .map_err(|error| error.to_string())
3154 })
3155 })
3156 });
3157 Ok(WasmBuildRecord {
3158 facts: input.resolved.facts.clone(),
3159 fingerprint: input.resolved.fingerprint,
3160 recipe_identity: input.resolved.recipe_identity,
3161 input_digest: input.resolved.input_digest,
3162 artifacts: expected_artifacts(spec, input.active_entry, input.targets).into(),
3163 retention,
3164 timings: WasmBuildTimings {
3165 lock_wait: input.lock_wait,
3166 shared_incremental_lock_wait: input.shared_incremental.lock_wait,
3167 input_resolution: input.input_resolution,
3168 cargo_build: input.cargo_build,
3169 cache_maintenance,
3170 total: total_started.elapsed(),
3171 },
3172 maintenance,
3173 shared_incremental_maintenance: input.shared_incremental.maintenance,
3174 })
3175}
3176
3177fn prune_wasm_build_cache_locked(
3178 target_dir: &Path,
3179 policy: CacheRetentionLimits,
3180 protected_entry: Option<&Path>,
3181) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3182 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
3183 prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
3184 .map_err(wasm_cache_fs_error)
3185}
3186
3187fn finish_fingerprint_build<T>(
3188 result: Result<T, WasmBuildError>,
3189 incomplete_directory: OwnedDirectory,
3190 progress: &mut ProgressReporter<'_>,
3191) -> Result<T, WasmBuildError> {
3192 match result {
3193 Ok(outcome) => {
3194 incomplete_directory.preserve();
3195 Ok(outcome)
3196 }
3197 Err(build_error) => Err(cleanup_failed_fingerprint_build(
3198 build_error,
3199 incomplete_directory,
3200 progress,
3201 )),
3202 }
3203}
3204
3205fn cleanup_failed_fingerprint_build(
3206 build_error: WasmBuildError,
3207 incomplete_directory: OwnedDirectory,
3208 progress: &mut ProgressReporter<'_>,
3209) -> WasmBuildError {
3210 let path = incomplete_directory.path().to_owned();
3211 let primary_phase = progress.failure_phase;
3212 let cleanup_started = Instant::now();
3213 let cleanup = incomplete_directory.cleanup();
3214 progress.record_phase(WasmBuildPhase::Cleanup, cleanup_started.elapsed());
3215 match cleanup {
3216 Ok(()) => {
3217 progress.failure_phase = primary_phase;
3218 build_error
3219 }
3220 Err(source) => WasmBuildError::FailedBuildCleanup {
3221 build_error: Box::new(build_error),
3222 path,
3223 source,
3224 },
3225 }
3226}
3227
3228fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
3229 create_dir_all(target_dir, "create Cargo target directory")?;
3230 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3231 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)
3232}
3233
3234fn lock_wasm_build_cache_with_progress(
3235 target_dir: &Path,
3236 progress: &mut ProgressReporter<'_>,
3237) -> Result<(File, Duration), WasmBuildError> {
3238 progress.begin_phase(WasmBuildPhase::ExactCacheCoordination);
3239 create_dir_all(target_dir, "create Cargo target directory")?;
3240 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3241 lock_cache_file_with_progress(&lock_path, WasmBuildPhase::ExactCacheCoordination, progress)
3242}
3243
3244fn lock_shared_incremental_target(
3245 spec: &WasmBuildSpec,
3246) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3247 lock_shared_incremental_target_internal(spec, None)
3248}
3249
3250fn lock_shared_incremental_target_with_progress(
3251 spec: &WasmBuildSpec,
3252 progress: &mut ProgressReporter<'_>,
3253) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3254 let target_dir = shared_incremental_target(spec)
3255 .expect("validated shared acquisition must have a shared Cargo target");
3256 progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted { target_dir });
3257 let (lock, wait, canonical) = lock_shared_incremental_target_internal(spec, Some(progress))?;
3258 progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
3259 target_dir: canonical.clone(),
3260 wait,
3261 });
3262 Ok((lock, wait, canonical))
3263}
3264
3265fn lock_shared_incremental_target_internal(
3266 spec: &WasmBuildSpec,
3267 mut progress: Option<&mut ProgressReporter<'_>>,
3268) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3269 if let Some(progress) = progress.as_deref_mut() {
3270 progress.begin_phase(WasmBuildPhase::SharedTargetCoordination);
3271 }
3272 let target_dir =
3273 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
3274 message: "shared incremental target is not configured".to_owned(),
3275 })?;
3276 create_dir_all(
3277 &target_dir,
3278 "create shared incremental Cargo target directory",
3279 )?;
3280 ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
3281 let canonical = target_dir
3282 .canonicalize()
3283 .map_err(|source| WasmBuildError::Io {
3284 operation: "resolve shared incremental Cargo target directory",
3285 path: target_dir.clone(),
3286 source,
3287 })?;
3288 let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
3289 let (lock, wait) = if let Some(progress) = progress {
3290 lock_cache_file_with_progress(
3291 &lock_path,
3292 WasmBuildPhase::SharedTargetCoordination,
3293 progress,
3294 )?
3295 } else {
3296 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)?
3297 };
3298 Ok((lock, wait, canonical))
3299}
3300
3301fn lock_cache_file_with_progress(
3302 lock_path: &Path,
3303 phase: WasmBuildPhase,
3304 progress: &mut ProgressReporter<'_>,
3305) -> Result<(File, Duration), WasmBuildError> {
3306 let started = Instant::now();
3307 progress.begin_phase(phase);
3308 let result = if !progress.is_observed() || progress.config.heartbeat_interval.is_none() {
3309 lock_cache_file(lock_path).map_err(wasm_cache_fs_error)
3310 } else {
3311 let heartbeat_interval = progress
3312 .config
3313 .heartbeat_interval
3314 .expect("observed cache lock must have a heartbeat interval");
3315 lock_cache_file_with_wait_observer(lock_path, heartbeat_interval, |elapsed| {
3316 progress.emit_heartbeat_if_due(phase, elapsed);
3317 })
3318 .map_err(wasm_cache_fs_error)
3319 };
3320 progress.record_phase(phase, started.elapsed());
3321 result
3322}
3323
3324fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
3325 ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
3326}
3327
3328fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
3329 record_entry_use(path).map_err(wasm_cache_fs_error)
3330}
3331
3332fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
3333 WasmBuildError::Io {
3334 operation: error.operation,
3335 path: error.path,
3336 source: error.source,
3337 }
3338}
3339
3340fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3341 for (key, value) in &spec.environment {
3342 let key = key.as_encoded_bytes();
3343 if key.is_empty()
3344 || key.iter().any(|byte| matches!(byte, b'=' | 0))
3345 || value.as_encoded_bytes().contains(&0)
3346 {
3347 return Err(WasmBuildError::InvalidSpec {
3348 message: "invalid child environment name or value".to_owned(),
3349 });
3350 }
3351 }
3352 if spec.cargo_message_bytes == 0 {
3353 return Err(WasmBuildError::InvalidSpec {
3354 message: "Cargo message allowance must be nonzero".to_owned(),
3355 });
3356 }
3357 if spec.packages.is_empty() {
3358 return Err(WasmBuildError::InvalidSpec {
3359 message: "at least one Cargo package is required".to_owned(),
3360 });
3361 }
3362 if spec.target.is_empty() {
3363 return Err(WasmBuildError::InvalidSpec {
3364 message: "Cargo compilation target must not be empty".to_owned(),
3365 });
3366 }
3367 if spec
3368 .environment
3369 .contains_key(OsStr::new("CARGO_TARGET_DIR"))
3370 {
3371 return Err(WasmBuildError::InvalidSpec {
3372 message: "Cargo target directories are owned by the build specification; use target_dir or with_shared_incremental_target instead of command overrides".to_owned(),
3373 });
3374 }
3375 validate_cargo_target_selection(spec)?;
3376 spec.cargo_selection
3377 .profile_target_dir()
3378 .map_err(|message| WasmBuildError::InvalidSpec {
3379 message: message.to_owned(),
3380 })?;
3381 if matches!(
3382 &spec.cache_mode,
3383 WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
3384 ) {
3385 return Err(WasmBuildError::InvalidSpec {
3386 message: "shared incremental Cargo target directory must not be empty".to_owned(),
3387 });
3388 }
3389 if spec.shared_incremental_maintenance_config.is_some()
3390 && !matches!(
3391 spec.cache_mode,
3392 WasmBuildCacheMode::SharedIncremental { .. }
3393 )
3394 {
3395 return Err(WasmBuildError::InvalidSpec {
3396 message:
3397 "scheduled shared-target maintenance requires a shared incremental Cargo target"
3398 .to_owned(),
3399 });
3400 }
3401 Ok(())
3402}
3403
3404fn validate_cargo_target_selection(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3405 if let Some(example) = &spec.example {
3406 let mut components = Path::new(example).components();
3407 if spec.packages.len() != 1
3408 || !matches!(
3409 (components.next(), components.next()),
3410 (Some(Component::Normal(_)), None)
3411 )
3412 {
3413 return Err(WasmBuildError::InvalidSpec {
3414 message: "a named Cargo example requires one package, a normal target name"
3415 .to_owned(),
3416 });
3417 }
3418 }
3419 if spec.packages.iter().any(|package| {
3420 let mut components = Path::new(package).components();
3421 !matches!(
3422 (components.next(), components.next()),
3423 (Some(Component::Normal(_)), None)
3424 )
3425 }) {
3426 return Err(WasmBuildError::InvalidSpec {
3427 message: "Cargo package names must be normal path components".to_owned(),
3428 });
3429 }
3430 Ok(())
3431}
3432
3433fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3434 build_fingerprint_with_progress(spec, &mut ProgressReporter::silent())
3435}
3436
3437fn build_fingerprint_with_progress(
3438 spec: &WasmBuildSpec,
3439 progress: &mut ProgressReporter<'_>,
3440) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3441 build_fingerprint_with_policy(spec, progress, &mut None)
3442}
3443
3444fn build_fingerprint_with_policy(
3445 spec: &WasmBuildSpec,
3446 progress: &mut ProgressReporter<'_>,
3447 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3448) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3449 let total_started = Instant::now();
3450 let (cargo_identity, rustc_identity, tool_identity) =
3451 resolve_tool_identity_with_policy(spec, progress, policy)?;
3452
3453 let metadata_started = Instant::now();
3454 let metadata = progress.run_phase(WasmBuildPhase::CargoMetadata, || {
3455 cargo_metadata_with_policy(spec, policy)
3456 })?;
3457 let cargo_metadata = metadata_started.elapsed();
3458
3459 let discovery_started = Instant::now();
3460 let (inputs, mut exclusions) = progress.run_phase(WasmBuildPhase::InputDiscovery, || {
3461 let parsed =
3462 ParsedCargoMetadata::parse(&metadata).map_err(|message| invalid_metadata(&message))?;
3463 resolve_local_inputs(spec, &parsed)
3464 })?;
3465 let input_discovery = discovery_started.elapsed();
3466
3467 let hashing_started = Instant::now();
3468 let (input_digest, validation_digest) =
3469 progress.run_phase(WasmBuildPhase::ContentHashing, || {
3470 let mut cache = LabeledPathDigestCache::default();
3471 digest_resolved_local_inputs(
3472 &inputs,
3473 &mut exclusions,
3474 &mut cache,
3475 &spec.workspace_root,
3476 "hash Wasm build inputs",
3477 "hash semantic Wasm build inputs",
3478 )
3479 })?;
3480 let content_hashing = hashing_started.elapsed();
3481
3482 Ok(ResolvedCargoBuildInputs::assemble(
3483 spec,
3484 inputs,
3485 exclusions,
3486 input_digest,
3487 validation_digest,
3488 spec.cargo_invocation()
3489 .facts(cargo_identity, rustc_identity),
3490 || WasmInputResolutionTimings {
3491 tool_identity,
3492 cargo_metadata,
3493 input_discovery,
3494 content_hashing,
3495 total: total_started.elapsed(),
3496 },
3497 ))
3498}
3499
3500fn cargo_build_inputs(inputs: Vec<(PathBuf, PathBuf)>) -> Arc<[CargoBuildInput]> {
3501 inputs
3502 .into_iter()
3503 .map(|(label, path)| CargoBuildInput { label, path })
3504 .collect()
3505}
3506
3507fn finish_build_fingerprint(
3508 spec: &WasmBuildSpec,
3509 facts: &CargoBuildFacts,
3510 input_digest: InputDigest,
3511) -> InputDigest {
3512 let mut hasher = InputHasher::new(BUILD_FINGERPRINT_DOMAIN);
3513 for package in &spec.packages {
3514 hasher.field("package", package.as_bytes());
3515 }
3516 hasher.field("target", facts.target.as_bytes());
3517 if let Some(example) = &spec.example {
3518 hasher.field("cargo-target-selection", b"example");
3519 hasher.field("cargo-example", example.as_bytes());
3520 } else {
3521 hasher.field("cargo-target-selection", b"lib");
3522 }
3523 hasher.field(
3524 "profile-target-dir",
3525 facts
3526 .selection
3527 .profile_target_dir()
3528 .expect("Cargo profile was validated before fingerprinting")
3529 .as_bytes(),
3530 );
3531 for argument in &facts.selection.build_arguments() {
3532 hasher.field("cargo-argument", &os_bytes(argument));
3533 }
3534 for (key, value) in &facts.environment {
3535 hasher.field("environment-key", &os_bytes(key));
3536 hasher.field("environment-value", &os_bytes(value));
3537 }
3538 hasher.field("cargo-identity", &facts.cargo_identity);
3539 hasher.field("rustc-identity", &facts.rustc_identity);
3540 hasher.field("source-input-digest", input_digest.as_bytes());
3541 hasher.finish()
3542}
3543
3544#[cfg(test)]
3545fn command_identity(
3546 spec: &WasmBuildSpec,
3547 phase: WasmBuildPhase,
3548 program: &OsStr,
3549 arguments: &[&str],
3550) -> Result<Vec<u8>, WasmBuildError> {
3551 command_identity_with_policy(spec, phase, program, arguments, &mut None)
3552}
3553
3554#[cfg(test)]
3555fn command_identity_with_policy(
3556 spec: &WasmBuildSpec,
3557 phase: WasmBuildPhase,
3558 program: &OsStr,
3559 arguments: &[&str],
3560 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3561) -> Result<Vec<u8>, WasmBuildError> {
3562 crate::cargo::execution::command_identity(
3563 program,
3564 &spec.workspace_root,
3565 &spec.environment,
3566 arguments,
3567 |command| {
3568 let output =
3569 capture_probe_output_with_policy(command, phase, TOOL_IDENTITY_BYTES, policy)?;
3570 ensure_command_success(phase, output)
3571 },
3572 )
3573}
3574
3575fn cargo_metadata(spec: &WasmBuildSpec) -> Result<schema::Metadata, WasmBuildError> {
3576 cargo_metadata_with_policy(spec, &mut None)
3577}
3578
3579fn cargo_metadata_with_policy(
3580 spec: &WasmBuildSpec,
3581 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3582) -> Result<schema::Metadata, WasmBuildError> {
3583 let mut command = Command::new(&spec.cargo_program);
3584 command
3585 .current_dir(&spec.workspace_root)
3586 .args(["metadata", "--format-version", "1"]);
3587 for argument in spec.cargo_selection.metadata_arguments() {
3588 command.arg(argument);
3589 }
3590 apply_command_environment(&mut command, spec);
3591 let output = capture_probe_output_with_policy(
3592 &mut command,
3593 WasmBuildPhase::CargoMetadata,
3594 CARGO_METADATA_BYTES,
3595 policy,
3596 )?;
3597 let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
3598 crate::cargo::observation::decode_metadata(&output.stdout).map_err(|error| {
3599 WasmBuildError::InvalidMetadata {
3600 message: format!("Cargo metadata did not match its schema: {error}"),
3601 }
3602 })
3603}
3604
3605#[cfg(test)]
3606fn capture_probe_output(
3607 command: &mut Command,
3608 phase: WasmBuildPhase,
3609 stdout_bytes: usize,
3610) -> Result<CompletedExecution, WasmBuildError> {
3611 capture_probe_output_with_policy(command, phase, stdout_bytes, &mut None)
3612}
3613
3614fn capture_probe_output_with_policy(
3615 command: &mut Command,
3616 phase: WasmBuildPhase,
3617 stdout_bytes: usize,
3618 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3619) -> Result<CompletedExecution, WasmBuildError> {
3620 let limits = OutputLimits {
3621 stdout: OutputLimit::Terminate(stdout_bytes),
3622 stderr: OutputLimit::Terminate(TOOL_IDENTITY_BYTES),
3623 timeout: None,
3624 };
3625 let result = if let Some(policy) = policy {
3626 capture_group_command_with_cancellation(
3627 command,
3628 limits,
3629 policy.cleanup,
3630 policy.deadline,
3631 &mut *policy.cancelled,
3632 )
3633 } else {
3634 capture_command(command, limits)
3635 }
3636 .and_then(CompletedExecution::require_complete);
3637 let evidence = match result {
3638 Ok(evidence) => evidence,
3639 Err(ToolError::Execution(error))
3640 if matches!(
3641 error.failure,
3642 ExecutionFailure::Io {
3643 operation: ExecutionOperation::Spawn,
3644 ..
3645 }
3646 ) && error.cleanup.is_none() =>
3647 {
3648 let ExecutionFailure::Io { source, .. } = error.failure else {
3649 unreachable!()
3650 };
3651 return Err(WasmBuildError::CommandSpawn {
3652 phase,
3653 program: command.get_program().to_owned(),
3654 source,
3655 });
3656 }
3657 Err(error) => {
3658 return Err(WasmBuildError::Io {
3659 operation: match phase {
3660 WasmBuildPhase::CargoIdentity => "capture complete Cargo identity probe output",
3661 WasmBuildPhase::RustcIdentity => "capture complete rustc identity probe output",
3662 _ => "capture complete Cargo metadata probe output",
3663 },
3664 path: PathBuf::from(command.get_program()),
3665 source: io::Error::other(error),
3666 });
3667 }
3668 };
3669 Ok(evidence)
3670}
3671
3672pub(super) fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
3673 let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
3674 return None;
3675 };
3676 Some(if target_dir.is_absolute() {
3677 target_dir.clone()
3678 } else {
3679 spec.workspace_root.join(target_dir)
3680 })
3681}
3682
3683fn shared_incremental_target_exists(
3684 spec: &WasmBuildSpec,
3685 operation: &'static str,
3686) -> Result<bool, WasmBuildError> {
3687 let target_dir =
3688 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
3689 message: "shared incremental target is not configured".to_owned(),
3690 })?;
3691 match fs::symlink_metadata(&target_dir) {
3692 Ok(metadata) if metadata.is_dir() => Ok(true),
3693 Ok(_) => Err(WasmBuildError::InvalidSpec {
3694 message: format!(
3695 "shared incremental Cargo target {} must be a directory",
3696 target_dir.display()
3697 ),
3698 }),
3699 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
3700 Err(source) => Err(WasmBuildError::Io {
3701 operation,
3702 path: target_dir,
3703 source,
3704 }),
3705 }
3706}
3707
3708fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
3709 command.env_clear().envs(&spec.environment);
3710}
3711
3712fn run_cargo_build(
3713 spec: &WasmBuildSpec,
3714 build_target_dir: &Path,
3715 targets: &[WasmArtifactTarget],
3716 progress: &mut ProgressReporter<'_>,
3717 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
3718) -> Result<Vec<PathBuf>, WasmBuildError> {
3719 let command = cargo_build_command(spec, build_target_dir)?;
3720 communicate_cargo_build(
3721 spec,
3722 build_target_dir,
3723 targets,
3724 command,
3725 progress,
3726 &mut spawn,
3727 )
3728}
3729
3730impl WasmBuildSpec {
3731 fn cargo_invocation(&self) -> CargoInvocation {
3732 CargoInvocation {
3733 cargo_program: self.cargo_program.clone(),
3734 working_directory: self.workspace_root.clone(),
3735 environment: self.environment.clone(),
3736 target: self.target.clone(),
3737 selection: self.cargo_selection.clone(),
3738 packages: self.packages.clone(),
3739 manifest_path: None,
3740 recipe: self.example.as_ref().map_or(CargoRecipe::Library, |name| {
3741 CargoRecipe::Example(name.clone())
3742 }),
3743 configurations: Vec::new(),
3744 render_diagnostics: false,
3745 keep_going: false,
3746 }
3747 }
3748}
3749
3750fn cargo_build_command(
3751 spec: &WasmBuildSpec,
3752 build_target_dir: &Path,
3753) -> Result<Command, WasmBuildError> {
3754 let absolute_target_dir =
3755 canonicalize_allow_missing(build_target_dir).map_err(|source| WasmBuildError::Io {
3756 operation: "resolve Cargo build target directory",
3757 path: build_target_dir.to_owned(),
3758 source,
3759 })?;
3760 let mut invocation = spec.cargo_invocation();
3761 invocation.environment.insert(
3762 "CARGO_TARGET_DIR".into(),
3763 absolute_target_dir.into_os_string(),
3764 );
3765 invocation.command().map_err(|source| WasmBuildError::Io {
3766 operation: "render Cargo build invocation",
3767 path: spec.workspace_root.clone(),
3768 source,
3769 })
3770}
3771
3772fn communicate_cargo_build(
3773 spec: &WasmBuildSpec,
3774 build_target_dir: &Path,
3775 targets: &[WasmArtifactTarget],
3776 mut command: Command,
3777 progress: &mut ProgressReporter<'_>,
3778 spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
3779) -> Result<Vec<PathBuf>, WasmBuildError> {
3780 command
3783 .stdin(Stdio::null())
3784 .stdout(Stdio::piped())
3785 .stderr(Stdio::piped());
3786 let started = Instant::now();
3787 let mut child = spawn(&mut command).map_err(|source| WasmBuildError::CommandSpawn {
3788 phase: WasmBuildPhase::CargoBuild,
3789 program: spec.cargo_program.clone(),
3790 source,
3791 })?;
3792 progress.emit(WasmBuildProgressEvent::CargoStarted {
3793 target_dir: build_target_dir.to_owned(),
3794 });
3795 let progress = RefCell::new(progress);
3796 let mut artifacts = vec![None; targets.len()];
3797 let mut collect = |message| collect_wasm_artifact(message, targets, &mut artifacts);
3798 let observed = communicate_cargo(
3799 &mut child,
3800 OutputLimits {
3801 stdout: OutputLimit::Truncate(CARGO_DIAGNOSTIC_BYTES),
3804 stderr: OutputLimit::Truncate(CARGO_DIAGNOSTIC_BYTES),
3805 timeout: None,
3806 },
3807 spec.cargo_message_bytes,
3808 || {
3809 progress
3810 .borrow_mut()
3811 .emit_heartbeat_if_due(WasmBuildPhase::CargoBuild, started.elapsed());
3812 false
3813 },
3814 |stream, bytes| {
3815 let mut progress = progress.borrow_mut();
3816 if progress.config.emit_cargo_output {
3817 progress.emit(WasmBuildProgressEvent::CargoOutput {
3818 stream,
3819 bytes: bytes.to_vec(),
3820 });
3821 }
3822 Ok(())
3823 },
3824 &mut collect,
3825 );
3826 let result = observed.execution;
3827 let status = match &result {
3828 Ok(evidence) => Some(evidence.status),
3829 Err(error) => error.evidence().and_then(|evidence| evidence.status),
3830 };
3831 if let Some(status) = status {
3832 progress
3833 .borrow_mut()
3834 .emit(WasmBuildProgressEvent::CargoFinished {
3835 success: status.success(),
3836 code: status.code(),
3837 elapsed: started.elapsed(),
3838 });
3839 }
3840 let evidence = result.map_err(|error| WasmBuildError::Io {
3841 operation: "communicate with cargo build",
3842 path: PathBuf::from(&spec.cargo_program),
3843 source: io::Error::other(error),
3845 })?;
3846 let status = evidence.status;
3847 if status.success() {
3848 observed.messages.map_err(|source| WasmBuildError::Io {
3849 operation: "decode Cargo build artifacts",
3850 path: PathBuf::from(&spec.cargo_program),
3851 source,
3852 })?;
3853 return artifacts
3854 .into_iter()
3855 .collect::<Option<Vec<_>>>()
3856 .ok_or_else(|| WasmBuildError::MissingArtifacts {
3857 paths: expected_artifacts(spec, build_target_dir, targets),
3858 });
3859 }
3860 Err(WasmBuildError::CommandFailed {
3861 phase: WasmBuildPhase::CargoBuild,
3862 status,
3863 stdout: cargo_diagnostic(&evidence.stdout, evidence.stdout_truncated),
3864 stderr: cargo_diagnostic(&evidence.stderr, evidence.stderr_truncated),
3865 })
3866}
3867
3868fn collect_wasm_artifact(
3869 message: schema::Message,
3870 targets: &[WasmArtifactTarget],
3871 artifacts: &mut [Option<PathBuf>],
3872) -> io::Result<()> {
3873 let schema::Message::CompilerArtifact(artifact) = message else {
3874 return Ok(());
3875 };
3876 let Some((_, selected)) = targets.iter().zip(artifacts).find(|(target, _)| {
3878 artifact_matches(&artifact, &target.package_id, &target.name, &target.kind)
3879 }) else {
3880 return Ok(());
3881 };
3882 if !artifact
3883 .target
3884 .crate_types
3885 .contains(&schema::CrateType::CDyLib)
3886 {
3887 return Err(io::Error::new(
3888 io::ErrorKind::InvalidData,
3889 "Cargo reported a non-cdylib Wasm target",
3890 ));
3891 }
3892 let mut matching = wasm_artifact_paths(&artifact);
3893 let path = matching
3894 .next()
3895 .filter(|path| path.is_absolute())
3896 .ok_or_else(|| {
3897 io::Error::new(
3898 io::ErrorKind::InvalidData,
3899 "Cargo omitted absolute Wasm artifact path",
3900 )
3901 })?;
3902 if matching.next().is_some() || selected.replace(path.to_path_buf()).is_some() {
3903 return Err(io::Error::new(
3904 io::ErrorKind::InvalidData,
3905 "Cargo reported ambiguous Wasm artifacts",
3906 ));
3907 }
3908 Ok(())
3909}
3910
3911fn cargo_diagnostic(bytes: &[u8], truncated: bool) -> String {
3912 let mut text = String::from_utf8_lossy(bytes).into_owned();
3913 if truncated {
3914 text.push_str("\n[diagnostic truncated after 1 MiB; enable Cargo output events for the complete stream]\n");
3915 }
3916 text
3917}
3918
3919fn ensure_command_success(
3920 phase: WasmBuildPhase,
3921 output: CompletedExecution,
3922) -> Result<CompletedExecution, WasmBuildError> {
3923 if output.status.success() {
3924 return Ok(output);
3925 }
3926 Err(WasmBuildError::CommandFailed {
3927 phase,
3928 status: output.status,
3929 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
3930 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
3931 })
3932}
3933
3934fn artifact_relative_path(spec: &WasmBuildSpec, target: &WasmArtifactTarget) -> PathBuf {
3935 let profile = Path::new(&spec.target).join(
3936 spec.cargo_selection
3937 .profile_target_dir()
3938 .expect("Cargo profile was validated before artifact discovery"),
3939 );
3940 let directory = if spec.example.is_some() {
3941 profile.join("examples")
3942 } else {
3943 profile
3944 };
3945 directory.join(format!("{}.wasm", target.name.replace('-', "_")))
3946}
3947
3948fn expected_artifacts(
3949 spec: &WasmBuildSpec,
3950 target_dir: &Path,
3951 targets: &[WasmArtifactTarget],
3952) -> Vec<PathBuf> {
3953 targets
3954 .iter()
3955 .map(|target| target_dir.join(artifact_relative_path(spec, target)))
3956 .collect()
3957}
3958
3959fn artifact_publication_paths(
3960 spec: &WasmBuildSpec,
3961 cache_entry: &Path,
3962 targets: &[WasmArtifactTarget],
3963) -> Vec<(PathBuf, PathBuf)> {
3964 targets
3965 .iter()
3966 .map(|target| {
3967 let relative = artifact_relative_path(spec, target);
3968 (cache_entry.join(&relative), spec.target_dir.join(relative))
3969 })
3970 .collect()
3971}
3972
3973#[derive(Debug, Eq, PartialEq)]
3974struct VerifiedWasmArtifact {
3975 cached_path: PathBuf,
3976 public_path: PathBuf,
3977 info: FileDigest,
3978}
3979
3980fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
3981 spec.target_dir
3982 .join(".ic-testkit/wasm-targets")
3983 .join(fingerprint.to_hex())
3984}
3985
3986fn validated_artifact_set(
3987 artifacts: Vec<(PathBuf, PathBuf)>,
3988 fingerprint: InputDigest,
3989) -> Option<Vec<VerifiedWasmArtifact>> {
3990 let header = artifact_stamp_header(fingerprint);
3991 let stamp_len =
3993 header.len() + ARTIFACT_STAMP_DIGEST_LABEL.len() + fingerprint.as_bytes().len() * 2 + 1;
3994 artifacts
3995 .into_iter()
3996 .map(|(artifact, public_path)| {
3997 let metadata = fs::metadata(&artifact).ok()?;
3998 if !metadata.is_file() || metadata.len() == 0 {
3999 return None;
4000 }
4001 let stamp =
4002 read_stamp_with_limit(&artifact_stamp_path(&artifact), stamp_len).ok()??;
4003 if stamp.len() != stamp_len || !stamp.starts_with(&header) {
4006 return None;
4007 }
4008 let info = digest_file("wasm-artifact-v1", &artifact).ok()?;
4009 (stamp == artifact_stamp_contents(fingerprint, info.digest)).then_some(
4010 VerifiedWasmArtifact {
4011 cached_path: artifact,
4012 public_path,
4013 info,
4014 },
4015 )
4016 })
4017 .collect()
4018}
4019
4020fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
4021 artifacts
4022 .iter()
4023 .filter(|path| {
4024 fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
4025 })
4026 .cloned()
4027 .collect()
4028}
4029
4030fn artifact_stamp_path(artifact: &Path) -> PathBuf {
4031 let mut name = artifact
4032 .file_name()
4033 .map_or_else(|| OsString::from("artifact"), OsString::from);
4034 name.push(".ic-testkit-build");
4035 artifact.with_file_name(name)
4036}
4037
4038fn artifact_stamp_header(fingerprint: InputDigest) -> String {
4039 format!("{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\n")
4040}
4041
4042fn artifact_stamp_contents(fingerprint: InputDigest, artifact_digest: InputDigest) -> String {
4043 format!(
4044 "{}{ARTIFACT_STAMP_DIGEST_LABEL}{artifact_digest}\n",
4045 artifact_stamp_header(fingerprint),
4046 )
4047}
4048
4049fn write_artifact_stamp(
4050 artifact: &Path,
4051 fingerprint: InputDigest,
4052 info: &FileDigest,
4053 preserve_matching: bool,
4054) -> Result<(), WasmBuildError> {
4055 let stamp_path = artifact_stamp_path(artifact);
4056 let stamp = artifact_stamp_contents(fingerprint, info.digest);
4057 if preserve_matching && destination_matches_bytes(&stamp_path, stamp.as_bytes()) {
4058 return Ok(());
4059 }
4060 ic_host_fs::durable::write_bytes(
4061 &stamp_path,
4062 ic_host_fs::durable::WriteOptions {
4063 mode: ic_host_fs::durable::PublicationMode::Replace,
4064 permissions: 0o666,
4065 },
4066 stamp.as_bytes(),
4067 )
4068 .map_err(|source| WasmBuildError::Io {
4069 operation: "publish Wasm build stamp",
4070 path: stamp_path,
4071 source: io::Error::other(source),
4072 })
4073}
4074
4075fn publish_artifact_stamps(
4076 artifacts: Vec<(PathBuf, PathBuf)>,
4077 fingerprint: InputDigest,
4078) -> Result<Vec<VerifiedWasmArtifact>, WasmBuildError> {
4079 artifacts
4080 .into_iter()
4081 .map(|(cached_path, public_path)| {
4082 let info = digest_file("wasm-artifact-v1", &cached_path).map_err(|source| {
4083 WasmBuildError::Io {
4084 operation: "hash built Wasm artifact",
4085 path: cached_path.clone(),
4086 source,
4087 }
4088 })?;
4089 write_artifact_stamp(&cached_path, fingerprint, &info, false)?;
4090 Ok(VerifiedWasmArtifact {
4091 cached_path,
4092 public_path,
4093 info,
4094 })
4095 })
4096 .collect()
4097}
4098
4099fn materialize_artifacts(
4100 artifacts: &[VerifiedWasmArtifact],
4101 fingerprint: InputDigest,
4102 preserve_matching: bool,
4103) -> Result<(), WasmBuildError> {
4104 for artifact in artifacts {
4105 materialize_verified_output(
4106 "wasm-artifact-v1",
4107 &artifact.cached_path,
4108 &artifact.public_path,
4109 preserve_matching.then_some(&artifact.info),
4110 )
4111 .map_err(|source| WasmBuildError::Io {
4112 operation: "publish Wasm artifact",
4113 path: artifact.public_path.clone(),
4114 source,
4115 })?;
4116 }
4117 for artifact in artifacts {
4119 write_artifact_stamp(
4120 &artifact.public_path,
4121 fingerprint,
4122 &artifact.info,
4123 preserve_matching,
4124 )?;
4125 }
4126 Ok(())
4127}
4128
4129fn copy_wasm_artifacts(
4130 source_artifacts: &[PathBuf],
4131 destinations: &[(PathBuf, PathBuf)],
4132) -> Result<(), WasmBuildError> {
4133 if source_artifacts.len() != destinations.len() {
4135 return Err(invalid_metadata(
4136 "Cargo artifact count does not match admitted outputs",
4137 ));
4138 }
4139 for (index, (cached, _)) in destinations.iter().enumerate() {
4140 let source = &source_artifacts[index];
4141 if source != cached {
4142 copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
4143 operation: "cache shared-incremental Wasm artifact",
4144 path: cached.clone(),
4145 source: source_error,
4146 })?;
4147 }
4148 }
4149 Ok(())
4150}
4151
4152fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
4153 fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
4154 operation,
4155 path: path.to_owned(),
4156 source,
4157 })
4158}
4159
4160fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
4161 if path.exists() {
4162 inputs.push((PathBuf::from(label), path));
4163 }
4164}
4165
4166fn invalid_metadata(message: &str) -> WasmBuildError {
4167 WasmBuildError::InvalidMetadata {
4168 message: message.to_owned(),
4169 }
4170}
4171
4172impl WasmBuildError {
4173 fn indicates_input_change(&self) -> bool {
4174 match self {
4175 Self::InputsChangedDuringAcquisition { .. } => true,
4176 Self::FailedBuildCleanup { build_error, .. } => build_error.indicates_input_change(),
4177 _ => false,
4178 }
4179 }
4180}
4181
4182impl std::fmt::Display for WasmBuildError {
4183 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4184 match self {
4185 Self::InvalidSpec { message } => {
4186 write!(formatter, "invalid Wasm build spec: {message}")
4187 }
4188 Self::Io {
4189 operation,
4190 path,
4191 source,
4192 } => write!(
4193 formatter,
4194 "failed to {operation} at {}: {source}",
4195 path.display()
4196 ),
4197 Self::CommandSpawn {
4198 phase,
4199 program,
4200 source,
4201 } => write!(
4202 formatter,
4203 "failed to launch {phase} using `{}`: {source}",
4204 program.to_string_lossy(),
4205 ),
4206 Self::CommandFailed {
4207 phase,
4208 status,
4209 stdout,
4210 stderr,
4211 } => write!(
4212 formatter,
4213 "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
4214 ),
4215 Self::InvalidMetadata { message } => {
4216 write!(formatter, "invalid Cargo metadata: {message}")
4217 }
4218 Self::InvalidCargoConfiguration { path, message } => write!(
4219 formatter,
4220 "invalid Cargo configuration at {}: {message}",
4221 path.display(),
4222 ),
4223 Self::MissingArtifacts { paths } => {
4224 formatter.write_str("cargo build succeeded without producing: ")?;
4225 for (index, path) in paths.iter().enumerate() {
4226 if index != 0 {
4227 formatter.write_str(", ")?;
4228 }
4229 write!(formatter, "{}", path.display())?;
4230 }
4231 Ok(())
4232 }
4233 Self::InputsChangedDuringAcquisition { before, after } => write!(
4234 formatter,
4235 "Wasm inputs changed during artifact acquisition: {before} -> {after}",
4236 ),
4237 Self::PreparedInputSnapshotInvalidated => formatter.write_str(
4238 "the prepared Wasm input snapshot was invalidated before artifact publication",
4239 ),
4240 Self::FailedBuildCleanup {
4241 build_error,
4242 path,
4243 source,
4244 } => write!(
4245 formatter,
4246 "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
4247 path.display(),
4248 ),
4249 }
4250 }
4251}
4252
4253impl std::error::Error for WasmBuildError {
4254 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
4255 match self {
4256 Self::Io { source, .. }
4257 | Self::CommandSpawn { source, .. }
4258 | Self::FailedBuildCleanup { source, .. } => Some(source),
4259 _ => None,
4260 }
4261 }
4262}
4263
4264#[cfg(test)]
4265mod tests;