1use super::phase::WasmBuildPhase;
2use crate::cargo::{
3 execution::{communicate_cargo, wasm_artifact_paths},
4 messages::artifact_matches,
5 observation::schema,
6 selection::CargoSelection,
7};
8use ic_host_fs::owned_directory::{OwnedDirectory, remove_path_if_present};
9use ic_host_fs::read::directory_logical_size;
10use ic_host_process::{
11 child::{CleanupPolicy, OwnedChild},
12 tool::{
13 CompletedExecution, ExecutionError, ExecutionEvidence, ExecutionFailure,
14 ExecutionOperation, OutputLimit, OutputLimits, OutputStream, SuccessfulExit, ToolError,
15 capture_command, communicate_child,
16 },
17};
18use std::{
19 cell::RefCell,
20 collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
21 ffi::{OsStr, OsString},
22 fs::{self, File},
23 io,
24 path::{Component, Path, PathBuf},
25 process::{Command, ExitStatus, Stdio},
26 sync::{
27 Arc, RwLock,
28 atomic::{AtomicUsize, Ordering},
29 mpsc::{self, RecvTimeoutError},
30 },
31 thread,
32 time::{Duration, Instant, SystemTime},
33};
34use toml::Value as TomlValue;
35
36mod acquisition;
37use acquisition::prepare_exact_cache_entry;
38
39use super::{
40 cache_fs::{
41 ArtifactCacheMaintenance, ArtifactCachePruneReport, CacheFsError, CacheRetentionLimits,
42 RetainedCacheEntry, cache_entry_last_used, cache_maintenance_due,
43 canonicalize_allow_missing, ensure_cache_directory_tag as ensure_cache_tag,
44 is_sha256_directory, lock_cache_file, lock_cache_file_with_wait_observer,
45 perform_scheduled_cache_maintenance, prune_direct_child_directories,
46 record_cache_entry_use as record_entry_use, record_cache_maintenance,
47 remove_unretained_entry,
48 },
49 digest::{
50 FileDigest, InputDigest, InputHasher, LabeledPathDigestCache, copy_file_atomic,
51 destination_matches_bytes, digest_bytes, digest_file, digest_labeled_paths_composable,
52 materialize_verified_output, os_bytes, read_stamp_with_limit,
53 },
54};
55
56const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
57const BUILD_FINGERPRINT_DOMAIN: &str = "ic-build-explicit-environment-fingerprint";
59const ARTIFACT_STAMP_DIGEST_LABEL: &str = "artifact-sha256:";
60const CARGO_DIAGNOSTIC_BYTES: usize = 1024 * 1024;
62const TOOL_IDENTITY_BYTES: usize = 64 * 1024;
63const CARGO_METADATA_BYTES: usize = 16 * 1024 * 1024;
64
65const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
66
67#[derive(Clone, Eq, PartialEq)]
75pub struct WasmBuildSpec {
76 workspace_root: PathBuf,
77 target_dir: PathBuf,
78 packages: Vec<String>,
79 example: Option<String>,
80 cargo_selection: CargoSelection,
81 environment: BTreeMap<OsString, OsString>,
82 additional_inputs: Vec<PathBuf>,
83 target: String,
84 cargo_program: OsString,
85 rustc_program: OsString,
86 cargo_message_bytes: usize,
87 cache_mode: WasmBuildCacheMode,
88 pruning: Option<(CacheRetentionLimits, Duration)>,
89 shared_incremental_maintenance_config: Option<SharedIncrementalTargetMaintenanceConfig>,
90}
91
92impl std::fmt::Debug for WasmBuildSpec {
93 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 formatter
95 .debug_struct("WasmBuildSpec")
96 .field("workspace_root", &self.workspace_root)
97 .field("target_dir", &self.target_dir)
98 .field("packages", &self.packages)
99 .field("example", &self.example)
100 .field("cargo_selection", &self.cargo_selection)
101 .field(
102 "environment_names",
103 &self.environment.keys().collect::<Vec<_>>(),
104 )
105 .field("additional_inputs", &self.additional_inputs)
106 .field("target", &self.target)
107 .field("cargo_program", &self.cargo_program)
108 .field("rustc_program", &self.rustc_program)
109 .field("cargo_message_bytes", &self.cargo_message_bytes)
110 .field("cache_mode", &self.cache_mode)
111 .field("pruning", &self.pruning)
112 .field(
113 "shared_incremental_maintenance_config",
114 &self.shared_incremental_maintenance_config,
115 )
116 .finish()
117 }
118}
119
120#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
122pub enum SharedIncrementalTargetMaintenanceFailureMode {
123 #[default]
125 Strict,
126 BestEffort,
128}
129
130#[derive(Clone, Copy, Debug, Eq, PartialEq)]
132pub struct SharedIncrementalTargetMaintenanceConfig {
133 policy: CacheRetentionLimits,
134 minimum_interval: Duration,
135 failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
136}
137
138#[non_exhaustive]
140#[derive(Clone, Debug, Eq, PartialEq)]
141pub enum WasmBuildCacheMode {
142 Isolated,
144 SharedIncremental {
146 target_dir: PathBuf,
148 },
149}
150
151#[derive(Clone, Debug, Eq, PartialEq)]
153pub enum WasmBuildOutcome {
154 Built(WasmBuildRecord),
156 Reused(WasmBuildRecord),
158}
159
160#[derive(Clone, Debug, Eq, PartialEq)]
162pub struct WasmBuildRecord {
163 facts: CargoBuildFacts,
164 fingerprint: InputDigest,
165 input_digest: InputDigest,
166 retention: RetainedCacheEntry,
167 artifacts: Arc<[PathBuf]>,
168 timings: WasmBuildTimings,
169 maintenance: Option<ArtifactCacheMaintenance>,
170 shared_incremental_maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
171}
172
173#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
175pub struct WasmBuildTimings {
176 lock_wait: Duration,
177 shared_incremental_lock_wait: Option<Duration>,
178 input_resolution: WasmInputResolutionTimings,
179 cargo_build: Option<Duration>,
180 cache_maintenance: Option<Duration>,
181 total: Duration,
182}
183
184#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
186pub struct WasmInputResolutionTimings {
187 tool_identity: Duration,
188 cargo_metadata: Duration,
189 input_discovery: Duration,
190 content_hashing: Duration,
191 total: Duration,
192}
193
194#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
196pub struct WasmBuildFailureTimings {
197 exact_cache_coordination: Duration,
198 shared_target_coordination: Option<Duration>,
199 input_resolution: WasmInputResolutionTimings,
200 shared_target_maintenance: Option<Duration>,
201 cargo_build: Option<Duration>,
202 artifact_publication: Option<Duration>,
203 exact_cache_maintenance: Option<Duration>,
204 cleanup: Option<Duration>,
205 total: Duration,
206}
207
208#[derive(Clone, Copy, Debug, Eq, PartialEq)]
210pub struct WasmBuildFailureDetails {
211 phase: WasmBuildPhase,
212 timings: WasmBuildFailureTimings,
213}
214
215#[derive(Clone, Debug, Eq, PartialEq)]
217pub struct CargoBuildInput {
218 label: PathBuf,
219 path: PathBuf,
220}
221
222#[derive(Clone, Eq, PartialEq)]
229pub struct CargoBuildFacts {
230 pub selection: CargoSelection,
232 pub target: String,
234 pub environment: BTreeMap<OsString, OsString>,
236 pub cargo_identity: Vec<u8>,
238 pub rustc_identity: Vec<u8>,
240}
241
242impl std::fmt::Debug for CargoBuildFacts {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 f.debug_struct("CargoBuildFacts")
245 .field("selection", &self.selection)
246 .field("target", &self.target)
247 .field("environment_entries", &self.environment.len())
248 .field("cargo_identity_bytes", &self.cargo_identity.len())
249 .field("rustc_identity_bytes", &self.rustc_identity.len())
250 .finish()
251 }
252}
253
254impl CargoBuildFacts {
255 fn observed(spec: &WasmBuildSpec, cargo_identity: Vec<u8>, rustc_identity: Vec<u8>) -> Self {
256 Self {
257 selection: spec.cargo_selection.clone(),
258 target: spec.target.clone(),
259 environment: spec.environment.clone(),
260 cargo_identity,
261 rustc_identity,
262 }
263 }
264}
265
266#[derive(Clone, Debug, Eq, PartialEq)]
273pub struct ResolvedCargoBuildInputs {
274 facts: CargoBuildFacts,
275 fingerprint: InputDigest,
276 input_digest: InputDigest,
277 validation_digest: InputDigest,
278 inputs: Arc<[CargoBuildInput]>,
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 workspace_projection: Option<InputDigest>,
321 wasm_targets: Result<Vec<WasmArtifactTarget>, String>,
322}
323
324#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
325pub(super) struct WasmBuildBatchInputMetrics {
326 pub(super) runs: usize,
327 pub(super) reuses: usize,
328 pub(super) prepared_reuses: usize,
329}
330
331#[derive(Eq, Hash, PartialEq)]
332struct BatchResolutionKey<'a> {
333 workspace_root: &'a Path,
334 cargo_program: &'a OsStr,
335 rustc_program: &'a OsStr,
336 metadata_arguments: Vec<OsString>,
337 environment: BTreeMap<OsString, OsString>,
338}
339
340#[derive(Clone, Debug, Eq, PartialEq)]
342pub struct SharedIncrementalTargetInspection {
343 target_dir: PathBuf,
344 logical_size_bytes: u64,
345 last_used: SystemTime,
346 lock_wait: Duration,
347}
348
349#[derive(Clone, Debug, Eq, PartialEq)]
351pub struct SharedIncrementalTargetMaintenance {
352 target_dir: PathBuf,
353 logical_size_bytes_before: u64,
354 logical_size_bytes_after: u64,
355 last_used_before: SystemTime,
356 cleared: bool,
357 lock_wait: Duration,
358 maintenance: Duration,
359}
360
361#[non_exhaustive]
363#[derive(Clone, Debug, Eq, PartialEq)]
364pub enum SharedIncrementalTargetMaintenanceOutcome {
365 Missing {
367 target_dir: PathBuf,
369 },
370 Skipped {
372 target_dir: PathBuf,
374 lock_wait: Duration,
376 schedule_check: Duration,
378 },
379 Performed {
381 maintenance: SharedIncrementalTargetMaintenance,
383 schedule_check: Duration,
385 },
386 Failed {
388 target_dir: PathBuf,
390 lock_wait: Duration,
392 message: String,
394 },
395}
396
397#[derive(Clone, Copy, Debug, Eq, PartialEq)]
399pub struct WasmBuildProgressConfig {
400 heartbeat_interval: Option<Duration>,
401 emit_cargo_output: bool,
402}
403
404#[derive(Clone, Copy, Debug, Eq, PartialEq)]
406pub enum WasmBuildProgressOutcome {
407 Built,
409 Reused,
411}
412
413#[non_exhaustive]
415#[derive(Clone, Debug, Eq, PartialEq)]
416pub enum WasmBuildProgressEvent {
417 Started,
419 InputsResolved {
421 fingerprint: InputDigest,
423 input_digest: InputDigest,
425 elapsed: Duration,
427 },
428 CacheMiss {
430 fingerprint: InputDigest,
432 },
433 CacheHit {
435 fingerprint: InputDigest,
437 },
438 SharedTargetLockStarted {
440 target_dir: PathBuf,
442 },
443 SharedTargetLockAcquired {
445 target_dir: PathBuf,
447 wait: Duration,
449 },
450 SharedTargetMaintenanceStarted {
452 target_dir: PathBuf,
454 },
455 SharedTargetMaintenanceFinished {
457 outcome: SharedIncrementalTargetMaintenanceOutcome,
459 },
460 CargoStarted {
462 target_dir: PathBuf,
464 },
465 CargoOutput {
467 stream: OutputStream,
469 bytes: Vec<u8>,
471 },
472 Heartbeat {
474 phase: WasmBuildPhase,
476 elapsed: Duration,
478 },
479 CargoFinished {
481 success: bool,
483 code: Option<i32>,
485 elapsed: Duration,
487 },
488 Finished {
490 outcome: WasmBuildProgressOutcome,
492 fingerprint: InputDigest,
494 elapsed: Duration,
496 },
497}
498
499impl Default for WasmBuildProgressConfig {
500 fn default() -> Self {
501 Self {
502 heartbeat_interval: Some(Duration::from_secs(10)),
503 emit_cargo_output: true,
504 }
505 }
506}
507
508impl WasmBuildProgressConfig {
509 #[must_use]
511 pub fn new() -> Self {
512 Self::default()
513 }
514
515 #[must_use]
519 pub const fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
520 self.heartbeat_interval = Some(interval);
521 self
522 }
523
524 #[must_use]
526 pub const fn without_heartbeats(mut self) -> Self {
527 self.heartbeat_interval = None;
528 self
529 }
530
531 #[must_use]
537 pub const fn with_cargo_output(mut self, emit: bool) -> Self {
538 self.emit_cargo_output = emit;
539 self
540 }
541
542 #[must_use]
544 pub const fn heartbeat_interval(self) -> Option<Duration> {
545 self.heartbeat_interval
546 }
547
548 #[must_use]
550 pub const fn emits_cargo_output(self) -> bool {
551 self.emit_cargo_output
552 }
553}
554
555struct ProgressReporter<'a> {
556 config: WasmBuildProgressConfig,
557 observer: Option<&'a mut dyn FnMut(WasmBuildProgressEvent)>,
558 last_event: Instant,
559 failure_phase: Option<WasmBuildPhase>,
560 failure_timings: WasmBuildFailureTimings,
561}
562
563impl<'a> ProgressReporter<'a> {
564 fn silent() -> Self {
565 Self {
566 config: WasmBuildProgressConfig {
567 heartbeat_interval: None,
568 emit_cargo_output: false,
569 },
570 observer: None,
571 last_event: Instant::now(),
572 failure_phase: None,
573 failure_timings: WasmBuildFailureTimings::default(),
574 }
575 }
576
577 fn new(
578 observation: Option<(
579 WasmBuildProgressConfig,
580 &'a mut dyn FnMut(WasmBuildProgressEvent),
581 )>,
582 ) -> Result<Self, WasmBuildError> {
583 let Some((config, observer)) = observation else {
584 return Ok(Self::silent());
585 };
586 if config.heartbeat_interval == Some(Duration::ZERO) {
587 return Err(WasmBuildError::InvalidSpec {
588 message: "Wasm build progress heartbeat interval must be greater than zero"
589 .to_owned(),
590 });
591 }
592 Ok(ProgressReporter {
593 config,
594 observer: Some(observer),
595 last_event: Instant::now(),
596 failure_phase: None,
597 failure_timings: WasmBuildFailureTimings::default(),
598 })
599 }
600
601 fn emit(&mut self, event: WasmBuildProgressEvent) {
602 if let Some(observer) = &mut self.observer {
603 observer(event);
604 self.last_event = Instant::now();
605 }
606 }
607
608 const fn is_observed(&self) -> bool {
609 self.observer.is_some()
610 }
611
612 fn heartbeat_due_in(&self) -> Option<Duration> {
613 self.config
614 .heartbeat_interval
615 .map(|interval| interval.saturating_sub(self.last_event.elapsed()))
616 }
617
618 fn emit_heartbeat(&mut self, phase: WasmBuildPhase, elapsed: Duration) {
619 self.emit(WasmBuildProgressEvent::Heartbeat { phase, elapsed });
620 }
621
622 fn emit_heartbeat_if_due(&mut self, phase: WasmBuildPhase, elapsed: Duration) {
623 if self.heartbeat_due_in() == Some(Duration::ZERO) {
624 self.emit_heartbeat(phase, elapsed);
625 }
626 }
627
628 fn run_phase<T, F>(&mut self, phase: WasmBuildPhase, operation: F) -> T
629 where
630 T: Send,
631 F: FnOnce() -> T + Send,
632 {
633 let started = Instant::now();
634 self.begin_phase(phase);
635 let result = if !self.is_observed() || self.config.heartbeat_interval.is_none() {
636 operation()
637 } else {
638 thread::scope(|scope| {
639 let (finished, completion) = mpsc::sync_channel(0);
640 let worker = scope.spawn(move || {
641 let result = operation();
642 let _ = finished.send(());
643 result
644 });
645 loop {
646 let wait = self
647 .heartbeat_due_in()
648 .expect("observed phase must have a heartbeat interval");
649 match completion.recv_timeout(wait) {
650 Ok(()) | Err(RecvTimeoutError::Disconnected) => {
651 return worker
652 .join()
653 .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
654 }
655 Err(RecvTimeoutError::Timeout) => {
656 self.emit_heartbeat(phase, started.elapsed());
657 }
658 }
659 }
660 })
661 };
662 self.record_phase(phase, started.elapsed());
663 result
664 }
665
666 const fn begin_phase(&mut self, phase: WasmBuildPhase) {
667 self.failure_phase = Some(phase);
668 }
669
670 fn record_phase(&mut self, phase: WasmBuildPhase, elapsed: Duration) {
671 self.failure_phase = Some(phase);
672 let timings = &mut self.failure_timings;
673 let timing = match phase {
676 WasmBuildPhase::Specification => return,
677 WasmBuildPhase::ExactCacheCoordination => &mut timings.exact_cache_coordination,
678 WasmBuildPhase::CargoIdentity | WasmBuildPhase::RustcIdentity => {
679 &mut timings.input_resolution.tool_identity
680 }
681 WasmBuildPhase::CargoMetadata => &mut timings.input_resolution.cargo_metadata,
682 WasmBuildPhase::InputDiscovery => &mut timings.input_resolution.input_discovery,
683 WasmBuildPhase::ContentHashing => &mut timings.input_resolution.content_hashing,
684 WasmBuildPhase::SharedTargetCoordination => timings
685 .shared_target_coordination
686 .get_or_insert(Duration::ZERO),
687 WasmBuildPhase::SharedTargetMaintenance => timings
688 .shared_target_maintenance
689 .get_or_insert(Duration::ZERO),
690 WasmBuildPhase::CargoBuild => timings.cargo_build.get_or_insert(Duration::ZERO),
691 WasmBuildPhase::ArtifactPublication => {
692 timings.artifact_publication.get_or_insert(Duration::ZERO)
693 }
694 WasmBuildPhase::ExactCacheMaintenance => timings
695 .exact_cache_maintenance
696 .get_or_insert(Duration::ZERO),
697 WasmBuildPhase::Cleanup => timings.cleanup.get_or_insert(Duration::ZERO),
698 };
699 *timing = timing.saturating_add(elapsed);
700 if matches!(
701 phase,
702 WasmBuildPhase::CargoIdentity
703 | WasmBuildPhase::RustcIdentity
704 | WasmBuildPhase::CargoMetadata
705 | WasmBuildPhase::InputDiscovery
706 | WasmBuildPhase::ContentHashing
707 ) {
708 timings.input_resolution.total = timings.input_resolution.total.saturating_add(elapsed);
709 }
710 }
711
712 fn failure_details(&self, error: &WasmBuildError, total: Duration) -> WasmBuildFailureDetails {
713 let phase = self
714 .failure_phase
715 .unwrap_or_else(|| classify_unobserved_failure(error));
716 let mut timings = self.failure_timings;
717 timings.total = total;
718 WasmBuildFailureDetails { phase, timings }
719 }
720}
721
722fn classify_unobserved_failure(error: &WasmBuildError) -> WasmBuildPhase {
723 match error {
724 WasmBuildError::InvalidSpec { .. } => WasmBuildPhase::Specification,
725 WasmBuildError::CommandSpawn { phase, .. }
726 | WasmBuildError::CommandFailed { phase, .. } => *phase,
727 WasmBuildError::InvalidMetadata { .. } => WasmBuildPhase::CargoMetadata,
728 WasmBuildError::InvalidCargoConfiguration { .. } => WasmBuildPhase::InputDiscovery,
729 WasmBuildError::MissingArtifacts { .. }
730 | WasmBuildError::PreparedInputSnapshotInvalidated => WasmBuildPhase::ArtifactPublication,
731 WasmBuildError::InputsChangedDuringAcquisition { .. } => WasmBuildPhase::ContentHashing,
732 WasmBuildError::FailedBuildCleanup { .. } => WasmBuildPhase::Cleanup,
733 WasmBuildError::Io { source, .. }
734 if source
735 .get_ref()
736 .is_some_and(<dyn std::error::Error + Send + Sync>::is::<ToolError>) =>
737 {
738 WasmBuildPhase::CargoBuild
739 }
740 WasmBuildError::Io { .. } => WasmBuildPhase::ExactCacheCoordination,
741 }
742}
743
744#[non_exhaustive]
746#[derive(Debug)]
747pub enum WasmBuildError {
748 InvalidSpec {
750 message: String,
752 },
753 Io {
755 operation: &'static str,
757 path: PathBuf,
759 source: io::Error,
761 },
762 CommandSpawn {
764 phase: WasmBuildPhase,
766 program: OsString,
768 source: io::Error,
770 },
771 CommandFailed {
775 phase: WasmBuildPhase,
777 status: ExitStatus,
779 stdout: String,
781 stderr: String,
783 },
784 InvalidMetadata {
786 message: String,
788 },
789 InvalidCargoConfiguration {
791 path: PathBuf,
793 message: String,
795 },
796 MissingArtifacts {
798 paths: Vec<PathBuf>,
800 },
801 InputsChangedDuringAcquisition {
803 before: InputDigest,
805 after: InputDigest,
807 },
808 PreparedInputSnapshotInvalidated,
810 FailedBuildCleanup {
812 build_error: Box<Self>,
814 path: PathBuf,
816 source: io::Error,
818 },
819}
820
821impl WasmBuildSpec {
822 #[must_use]
828 pub const fn with_cargo_message_bytes(mut self, maximum: usize) -> Self {
829 self.cargo_message_bytes = maximum;
830 self
831 }
832
833 #[must_use]
857 pub fn new<I, K, V>(
858 workspace_root: &Path,
859 target_dir: &Path,
860 packages: &[&str],
861 environment: I,
862 ) -> Self
863 where
864 I: IntoIterator<Item = (K, V)>,
865 K: Into<OsString>,
866 V: Into<OsString>,
867 {
868 let environment: BTreeMap<OsString, OsString> = environment
869 .into_iter()
870 .map(|(key, value)| (key.into(), value.into()))
871 .collect();
872 let mut packages = packages
873 .iter()
874 .map(|package| (*package).to_owned())
875 .collect::<Vec<_>>();
876 packages.sort();
877 packages.dedup();
878 Self {
879 workspace_root: workspace_root.to_owned(),
880 target_dir: target_dir.to_owned(),
881 packages,
882 example: None,
883 cargo_selection: CargoSelection::default(),
884 cargo_program: environment
885 .get(OsStr::new("CARGO"))
886 .cloned()
887 .unwrap_or_else(|| "cargo".into()),
888 rustc_program: environment
889 .get(OsStr::new("RUSTC"))
890 .cloned()
891 .unwrap_or_else(|| "rustc".into()),
892 environment,
893 additional_inputs: Vec::new(),
894 target: DEFAULT_TARGET.to_owned(),
895 cargo_message_bytes: CARGO_METADATA_BYTES,
896 cache_mode: WasmBuildCacheMode::SharedIncremental {
897 target_dir: PathBuf::from("target/ic-testkit-incremental"),
898 },
899 pruning: None,
900 shared_incremental_maintenance_config: None,
901 }
902 }
903
904 #[must_use]
910 pub fn with_example(mut self, name: impl Into<String>) -> Self {
911 self.example = Some(name.into());
912 self
913 }
914
915 #[must_use]
917 pub fn example(&self) -> Option<&str> {
918 self.example.as_deref()
919 }
920
921 pub fn cargo_build_command(&self) -> Result<Command, WasmBuildError> {
933 validate_spec(self)?;
934 cargo_build_command(self, &self.target_dir)
935 }
936
937 #[must_use]
942 pub fn with_cargo_selection(mut self, selection: CargoSelection) -> Self {
943 self.cargo_selection = selection;
944 self
945 }
946
947 #[must_use]
953 pub fn with_environment<I, K, V>(mut self, environment: I) -> Self
954 where
955 I: IntoIterator<Item = (K, V)>,
956 K: Into<OsString>,
957 V: Into<OsString>,
958 {
959 self.environment = environment
960 .into_iter()
961 .map(|(key, value)| (key.into(), value.into()))
962 .collect();
963 self
964 }
965
966 #[must_use]
968 pub const fn environment(&self) -> &BTreeMap<OsString, OsString> {
969 &self.environment
970 }
971
972 #[must_use]
977 pub fn with_additional_inputs<I, P>(mut self, paths: I) -> Self
978 where
979 I: IntoIterator<Item = P>,
980 P: Into<PathBuf>,
981 {
982 self.additional_inputs
983 .extend(paths.into_iter().map(Into::into));
984 self
985 }
986
987 #[must_use]
989 pub fn with_target(mut self, target: &str) -> Self {
990 target.clone_into(&mut self.target);
991 self
992 }
993
994 #[must_use]
996 pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
997 self.cargo_program = program.into();
998 self
999 }
1000
1001 #[must_use]
1009 pub fn with_rustc_identity_program(mut self, program: impl Into<OsString>) -> Self {
1010 self.rustc_program = program.into();
1011 self
1012 }
1013
1014 #[must_use]
1020 pub fn with_shared_incremental_target(mut self, target_dir: impl Into<PathBuf>) -> Self {
1021 self.cache_mode = WasmBuildCacheMode::SharedIncremental {
1022 target_dir: target_dir.into(),
1023 };
1024 self
1025 }
1026
1027 #[must_use]
1032 pub fn with_isolated_builds(mut self) -> Self {
1033 self.cache_mode = WasmBuildCacheMode::Isolated;
1034 self
1035 }
1036
1037 #[must_use]
1048 pub const fn with_shared_incremental_target_maintenance_at_most_every(
1049 mut self,
1050 policy: CacheRetentionLimits,
1051 minimum_interval: Duration,
1052 ) -> Self {
1053 self.shared_incremental_maintenance_config = Some(
1054 SharedIncrementalTargetMaintenanceConfig::new(policy, minimum_interval),
1055 );
1056 self
1057 }
1058
1059 #[must_use]
1065 pub const fn with_shared_incremental_target_maintenance(
1066 mut self,
1067 config: SharedIncrementalTargetMaintenanceConfig,
1068 ) -> Self {
1069 self.shared_incremental_maintenance_config = Some(config);
1070 self
1071 }
1072
1073 #[must_use]
1079 pub const fn with_prune_policy(mut self, policy: CacheRetentionLimits) -> Self {
1080 self.pruning = Some((policy, Duration::ZERO));
1081 self
1082 }
1083
1084 #[must_use]
1091 pub const fn with_prune_policy_at_most_every(
1092 mut self,
1093 policy: CacheRetentionLimits,
1094 minimum_interval: Duration,
1095 ) -> Self {
1096 self.pruning = Some((policy, minimum_interval));
1097 self
1098 }
1099
1100 #[must_use]
1102 pub fn workspace_root(&self) -> &Path {
1103 &self.workspace_root
1104 }
1105
1106 #[must_use]
1108 pub fn target_dir(&self) -> &Path {
1109 &self.target_dir
1110 }
1111
1112 #[must_use]
1114 pub fn packages(&self) -> &[String] {
1115 &self.packages
1116 }
1117
1118 #[must_use]
1120 pub const fn cache_mode(&self) -> &WasmBuildCacheMode {
1121 &self.cache_mode
1122 }
1123
1124 #[must_use]
1126 pub const fn prune_policy(&self) -> Option<CacheRetentionLimits> {
1127 match self.pruning {
1128 Some((policy, _)) => Some(policy),
1129 None => None,
1130 }
1131 }
1132
1133 #[must_use]
1138 pub const fn prune_interval(&self) -> Option<Duration> {
1139 match self.pruning {
1140 Some((_, interval)) if !interval.is_zero() => Some(interval),
1141 _ => None,
1142 }
1143 }
1144
1145 #[must_use]
1147 pub const fn shared_incremental_target_maintenance(
1148 &self,
1149 ) -> Option<SharedIncrementalTargetMaintenanceConfig> {
1150 self.shared_incremental_maintenance_config
1151 }
1152}
1153
1154impl WasmBuildOutcome {
1155 #[must_use]
1157 pub const fn record(&self) -> &WasmBuildRecord {
1158 match self {
1159 Self::Built(record) | Self::Reused(record) => record,
1160 }
1161 }
1162
1163 #[must_use]
1165 pub const fn is_reused(&self) -> bool {
1166 matches!(self, Self::Reused(_))
1167 }
1168}
1169
1170impl WasmBuildRecord {
1171 #[must_use]
1177 pub const fn facts(&self) -> &CargoBuildFacts {
1178 &self.facts
1179 }
1180
1181 #[must_use]
1183 pub const fn fingerprint(&self) -> InputDigest {
1184 self.fingerprint
1185 }
1186
1187 #[must_use]
1189 pub const fn input_digest(&self) -> InputDigest {
1190 self.input_digest
1191 }
1192
1193 #[must_use]
1200 pub fn exact_cache_path(&self) -> &Path {
1201 self.retention.path()
1202 }
1203
1204 #[must_use]
1209 pub fn artifacts(&self) -> &[PathBuf] {
1210 &self.artifacts
1211 }
1212
1213 #[must_use]
1215 pub const fn timings(&self) -> WasmBuildTimings {
1216 self.timings
1217 }
1218
1219 #[must_use]
1221 pub const fn maintenance(&self) -> Option<&ArtifactCacheMaintenance> {
1222 self.maintenance.as_ref()
1223 }
1224
1225 #[must_use]
1227 pub const fn shared_incremental_maintenance(
1228 &self,
1229 ) -> Option<&SharedIncrementalTargetMaintenanceOutcome> {
1230 self.shared_incremental_maintenance.as_ref()
1231 }
1232}
1233
1234impl WasmBuildTimings {
1235 #[must_use]
1237 pub const fn lock_wait(self) -> Duration {
1238 self.lock_wait
1239 }
1240
1241 #[must_use]
1243 pub const fn shared_incremental_lock_wait(self) -> Option<Duration> {
1244 self.shared_incremental_lock_wait
1245 }
1246
1247 #[must_use]
1249 pub const fn input_resolution(self) -> WasmInputResolutionTimings {
1250 self.input_resolution
1251 }
1252
1253 #[must_use]
1255 pub const fn cargo_build(self) -> Option<Duration> {
1256 self.cargo_build
1257 }
1258
1259 #[must_use]
1261 pub const fn cache_maintenance(self) -> Option<Duration> {
1262 self.cache_maintenance
1263 }
1264
1265 #[must_use]
1267 pub const fn total(self) -> Duration {
1268 self.total
1269 }
1270
1271 pub(super) fn saturating_add(self, other: Self) -> Self {
1272 let mut input_resolution = self.input_resolution;
1273 input_resolution.include(other.input_resolution);
1274 Self {
1275 lock_wait: self.lock_wait.saturating_add(other.lock_wait),
1276 shared_incremental_lock_wait: [
1277 self.shared_incremental_lock_wait,
1278 other.shared_incremental_lock_wait,
1279 ]
1280 .into_iter()
1281 .flatten()
1282 .reduce(Duration::saturating_add),
1283 input_resolution,
1284 cargo_build: [self.cargo_build, other.cargo_build]
1285 .into_iter()
1286 .flatten()
1287 .reduce(Duration::saturating_add),
1288 cache_maintenance: [self.cache_maintenance, other.cache_maintenance]
1289 .into_iter()
1290 .flatten()
1291 .reduce(Duration::saturating_add),
1292 total: self.total.saturating_add(other.total),
1293 }
1294 }
1295}
1296
1297impl WasmInputResolutionTimings {
1298 #[must_use]
1300 pub const fn tool_identity(self) -> Duration {
1301 self.tool_identity
1302 }
1303
1304 #[must_use]
1306 pub const fn cargo_metadata(self) -> Duration {
1307 self.cargo_metadata
1308 }
1309
1310 #[must_use]
1312 pub const fn input_discovery(self) -> Duration {
1313 self.input_discovery
1314 }
1315
1316 #[must_use]
1318 pub const fn content_hashing(self) -> Duration {
1319 self.content_hashing
1320 }
1321
1322 #[must_use]
1324 pub const fn total(self) -> Duration {
1325 self.total
1326 }
1327
1328 const fn include(&mut self, other: Self) {
1329 self.tool_identity = self.tool_identity.saturating_add(other.tool_identity);
1330 self.cargo_metadata = self.cargo_metadata.saturating_add(other.cargo_metadata);
1331 self.input_discovery = self.input_discovery.saturating_add(other.input_discovery);
1332 self.content_hashing = self.content_hashing.saturating_add(other.content_hashing);
1333 self.total = self.total.saturating_add(other.total);
1334 }
1335}
1336
1337impl WasmBuildFailureDetails {
1338 pub(super) fn specification(total: Duration) -> Self {
1339 Self {
1340 phase: WasmBuildPhase::Specification,
1341 timings: WasmBuildFailureTimings {
1342 total,
1343 ..WasmBuildFailureTimings::default()
1344 },
1345 }
1346 }
1347
1348 #[must_use]
1350 pub const fn phase(self) -> WasmBuildPhase {
1351 self.phase
1352 }
1353
1354 #[must_use]
1356 pub const fn timings(self) -> WasmBuildFailureTimings {
1357 self.timings
1358 }
1359}
1360
1361impl WasmBuildFailureTimings {
1362 #[must_use]
1364 pub const fn exact_cache_coordination(self) -> Duration {
1365 self.exact_cache_coordination
1366 }
1367
1368 #[must_use]
1370 pub const fn shared_target_coordination(self) -> Option<Duration> {
1371 self.shared_target_coordination
1372 }
1373
1374 #[must_use]
1376 pub const fn input_resolution(self) -> WasmInputResolutionTimings {
1377 self.input_resolution
1378 }
1379
1380 #[must_use]
1382 pub const fn shared_target_maintenance(self) -> Option<Duration> {
1383 self.shared_target_maintenance
1384 }
1385
1386 #[must_use]
1388 pub const fn cargo_build(self) -> Option<Duration> {
1389 self.cargo_build
1390 }
1391
1392 #[must_use]
1394 pub const fn artifact_publication(self) -> Option<Duration> {
1395 self.artifact_publication
1396 }
1397
1398 #[must_use]
1400 pub const fn exact_cache_maintenance(self) -> Option<Duration> {
1401 self.exact_cache_maintenance
1402 }
1403
1404 #[must_use]
1406 pub const fn cleanup(self) -> Option<Duration> {
1407 self.cleanup
1408 }
1409
1410 #[must_use]
1412 pub const fn total(self) -> Duration {
1413 self.total
1414 }
1415}
1416
1417impl CargoBuildInput {
1418 #[must_use]
1420 pub fn label(&self) -> &Path {
1421 &self.label
1422 }
1423
1424 #[must_use]
1429 pub fn path(&self) -> &Path {
1430 &self.path
1431 }
1432}
1433
1434pub struct CargoInputProbePolicy<'a> {
1441 pub deadline: Option<Instant>,
1444 pub cleanup: CleanupPolicy,
1446 pub cancelled: &'a mut (dyn FnMut() -> bool + Send),
1449}
1450
1451impl ResolvedCargoBuildInputs {
1452 #[must_use]
1454 pub const fn facts(&self) -> &CargoBuildFacts {
1455 &self.facts
1456 }
1457
1458 fn assemble(
1459 spec: &WasmBuildSpec,
1460 inputs: ResolvedLocalInputs,
1461 exclusions: Vec<PathBuf>,
1462 input_digest: InputDigest,
1463 validation_digest: InputDigest,
1464 facts: CargoBuildFacts,
1465 timings: impl FnOnce() -> WasmInputResolutionTimings,
1466 ) -> Self {
1467 Self {
1468 fingerprint: finish_build_fingerprint(spec, &facts, input_digest),
1469 facts,
1470 input_digest,
1471 validation_digest,
1472 inputs: cargo_build_inputs(inputs.validation_inputs),
1473 exclusions: exclusions.into(),
1474 wasm_outputs: inputs.wasm_targets.map(|targets| ResolvedWasmOutputs {
1475 artifact_paths: expected_artifacts(spec, &spec.target_dir, &targets).into(),
1476 targets: targets.into(),
1477 }),
1478 timings: timings(),
1480 }
1481 }
1482
1483 pub fn artifact_paths(&self) -> Result<&[PathBuf], WasmBuildError> {
1493 self.wasm_outputs()
1494 .map(|outputs| outputs.artifact_paths.as_ref())
1495 }
1496
1497 fn wasm_outputs(&self) -> Result<&ResolvedWasmOutputs, WasmBuildError> {
1498 self.wasm_outputs
1499 .as_ref()
1500 .map_err(|message| WasmBuildError::InvalidSpec {
1501 message: message.clone(),
1502 })
1503 }
1504
1505 #[must_use]
1507 pub const fn fingerprint(&self) -> InputDigest {
1508 self.fingerprint
1509 }
1510
1511 #[must_use]
1516 pub const fn input_digest(&self) -> InputDigest {
1517 self.input_digest
1518 }
1519
1520 #[must_use]
1525 pub const fn validation_digest(&self) -> InputDigest {
1526 self.validation_digest
1527 }
1528
1529 #[must_use]
1531 pub fn inputs(&self) -> &[CargoBuildInput] {
1532 &self.inputs
1533 }
1534
1535 #[must_use]
1540 pub fn exclusions(&self) -> &[PathBuf] {
1541 &self.exclusions
1542 }
1543
1544 #[must_use]
1546 pub const fn timings(&self) -> WasmInputResolutionTimings {
1547 self.timings
1548 }
1549
1550 pub fn is_current(&self, spec: &WasmBuildSpec) -> Result<bool, WasmBuildError> {
1555 resolve_cargo_build_inputs(spec).map(|current| current.fingerprint == self.fingerprint)
1556 }
1557
1558 pub fn is_current_with_probe_policy(
1566 &self,
1567 spec: &WasmBuildSpec,
1568 policy: &mut CargoInputProbePolicy<'_>,
1569 ) -> Result<bool, WasmBuildError> {
1570 resolve_cargo_build_inputs_with_probe_policy(spec, policy)
1571 .map(|current| current.fingerprint == self.fingerprint)
1572 }
1573
1574 pub fn is_content_current(&self) -> Result<bool, WasmBuildError> {
1584 self.current_validation_digest()
1585 .map(|current| current == self.validation_digest)
1586 }
1587
1588 pub(super) fn current_validation_digest(&self) -> Result<InputDigest, WasmBuildError> {
1589 digest_labeled_paths_composable(
1590 "wasm-source-inputs-v1",
1591 self.inputs
1592 .iter()
1593 .map(|input| (input.label.as_path(), input.path.as_path())),
1594 &self.exclusions,
1595 &mut LabeledPathDigestCache::default(),
1596 )
1597 .map_err(|source| WasmBuildError::Io {
1598 operation: "rehash resolved Cargo build inputs",
1599 path: self
1600 .inputs
1601 .first()
1602 .map_or_else(PathBuf::new, |input| input.path.clone()),
1603 source,
1604 })
1605 }
1606}
1607
1608impl WasmBuildInputSnapshotState {
1609 pub(super) fn prepare(specs: &[WasmBuildSpec]) -> Result<Self, WasmBuildError> {
1610 for spec in specs {
1611 validate_spec(spec)?;
1612 }
1613 let mut resolver = WasmBuildBatchInputResolver::new(specs, None)
1614 .map_err(|_| WasmBuildError::PreparedInputSnapshotInvalidated)?;
1615 let mut snapshots = Vec::with_capacity(specs.len());
1616 let mut preparation_timings = WasmInputResolutionTimings::default();
1617 let mut progress = ProgressReporter::silent();
1618 for (index, spec) in specs.iter().enumerate() {
1619 let mut prepared_input = resolver.resolve(index, &mut progress)?;
1620 preparation_timings.include(prepared_input.timings);
1621 prepared_input.timings = WasmInputResolutionTimings::default();
1622 snapshots.push((spec.clone(), prepared_input));
1623 }
1624 Ok(Self {
1625 snapshots,
1626 preparation_metrics: resolver.metrics(),
1627 preparation_timings,
1628 reader_reuses: AtomicUsize::new(0),
1629 invalidation: Arc::new(RwLock::new(false)),
1630 })
1631 }
1632
1633 fn admit(&self, specs: &[&WasmBuildSpec]) -> Result<Vec<ResolvedCargoBuildInputs>, usize> {
1634 let matched = specs
1635 .iter()
1636 .enumerate()
1637 .map(|(index, spec)| {
1638 self.snapshots
1639 .iter()
1640 .find(|(candidate, _)| candidate == *spec)
1641 .map(|(_, snapshot)| snapshot.clone())
1642 .ok_or(index)
1643 })
1644 .collect::<Result<Vec<_>, _>>()?;
1645 #[allow(deprecated, reason = "try_update is unstable at the Rust 1.91 MSRV")]
1646 let _ = self
1647 .reader_reuses
1648 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
1649 Some(current.saturating_add(matched.len()))
1650 });
1651 Ok(matched)
1652 }
1653
1654 pub(super) const fn specification_count(&self) -> usize {
1655 self.snapshots.len()
1656 }
1657
1658 pub(super) const fn preparation_metrics(&self) -> WasmBuildBatchInputMetrics {
1659 self.preparation_metrics
1660 }
1661
1662 pub(super) const fn preparation_timings(&self) -> WasmInputResolutionTimings {
1663 self.preparation_timings
1664 }
1665
1666 pub(super) fn reader_reuses(&self) -> usize {
1667 self.reader_reuses.load(Ordering::Relaxed)
1668 }
1669
1670 pub(super) fn is_invalidated(&self) -> bool {
1671 *self
1672 .invalidation
1673 .read()
1674 .unwrap_or_else(std::sync::PoisonError::into_inner)
1675 }
1676
1677 fn invalidate(&self) {
1678 *self
1679 .invalidation
1680 .write()
1681 .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
1682 }
1683
1684 fn invalidation(&self) -> Arc<RwLock<bool>> {
1685 Arc::clone(&self.invalidation)
1686 }
1687}
1688
1689impl<'a, 'session> WasmBuildBatchInputResolver<'a, 'session> {
1690 pub(super) fn new(
1691 specs: impl IntoIterator<Item = &'a WasmBuildSpec>,
1692 reuse: Option<&'session WasmBuildInputSnapshotState>,
1693 ) -> Result<Self, usize> {
1694 let specs = specs.into_iter().collect::<Vec<_>>();
1695 let prepared = match reuse.as_ref() {
1696 Some(snapshot) => Some(snapshot.admit(&specs)?),
1697 _ => None,
1698 };
1699 let mut groups_by_key = HashMap::new();
1700 let mut groups = Vec::<BatchResolutionGroup>::new();
1701 let mut group_by_index = Vec::with_capacity(specs.len());
1702 for (index, spec) in specs.iter().copied().enumerate() {
1703 let group = *groups_by_key
1704 .entry(BatchResolutionKey::for_spec(spec))
1705 .or_insert_with(|| {
1706 groups.push(BatchResolutionGroup {
1707 indexes: Vec::new(),
1708 });
1709 groups.len() - 1
1710 });
1711 groups[group].indexes.push(index);
1712 group_by_index.push(group);
1713 }
1714 let mut metrics = WasmBuildBatchInputMetrics::default();
1715 let resolved = prepared.map_or_else(
1716 || specs.iter().map(|_| None).collect(),
1717 |prepared| {
1718 metrics.prepared_reuses = prepared.len();
1719 prepared
1720 .into_iter()
1721 .map(|snapshot| Some(Ok(snapshot)))
1722 .collect()
1723 },
1724 );
1725 Ok(Self {
1726 specs,
1727 groups,
1728 group_by_index,
1729 resolved,
1730 reuse,
1731 metrics,
1732 })
1733 }
1734
1735 pub(super) const fn metrics(&self) -> WasmBuildBatchInputMetrics {
1736 self.metrics
1737 }
1738
1739 pub(super) fn invalidate_source_lease(&self) {
1740 if let Some(snapshot) = self.reuse {
1741 snapshot.invalidate();
1742 }
1743 }
1744
1745 pub(super) fn prepared_invalidation(&self) -> Option<Arc<RwLock<bool>>> {
1746 self.reuse.map(WasmBuildInputSnapshotState::invalidation)
1747 }
1748
1749 fn resolve(
1750 &mut self,
1751 index: usize,
1752 progress: &mut ProgressReporter<'_>,
1753 ) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1754 if self.resolved[index].is_none() {
1755 self.resolve_group(index, progress)?;
1756 }
1757 self.resolved[index]
1758 .take()
1759 .expect("resolved batch input must be populated")
1760 .map_err(|(phase, error)| {
1761 progress.begin_phase(phase);
1762 error
1763 })
1764 }
1765
1766 fn resolve_group(
1767 &mut self,
1768 active_index: usize,
1769 progress: &mut ProgressReporter<'_>,
1770 ) -> Result<(), WasmBuildError> {
1771 let total_started = Instant::now();
1772 let group = self.group_by_index[active_index];
1773 let active = self.specs[active_index];
1774
1775 let (cargo_identity, rustc_identity, tool_identity) =
1776 resolve_tool_identity(active, progress)?;
1777
1778 let metadata_started = Instant::now();
1779 let metadata =
1780 progress.run_phase(WasmBuildPhase::CargoMetadata, || cargo_metadata(active))?;
1781 let cargo_metadata = metadata_started.elapsed();
1782
1783 let (discovered, input_discovery) = self.discover_group_inputs(group, &metadata, progress);
1784
1785 let hashing_started = Instant::now();
1786 let mut batch_digest_cache = LabeledPathDigestCache::default();
1787 let digest_cache = &mut batch_digest_cache;
1788 let workspace_root = &active.workspace_root;
1789 let resolved_inputs = progress.run_phase(WasmBuildPhase::ContentHashing, || {
1790 discovered
1791 .into_iter()
1792 .map(|(index, inputs, exclusions)| {
1793 let result = digest_resolved_local_inputs(
1794 &inputs,
1795 &exclusions,
1796 digest_cache,
1797 workspace_root,
1798 "hash batched Wasm build inputs",
1799 "hash batched semantic Wasm build inputs",
1800 )
1801 .map(|(input_digest, validation_digest)| {
1802 (inputs, exclusions, input_digest, validation_digest)
1803 });
1804 (index, result)
1805 })
1806 .collect::<Vec<_>>()
1807 });
1808 let content_hashing = hashing_started.elapsed();
1809 let timings = WasmInputResolutionTimings {
1810 tool_identity,
1811 cargo_metadata,
1812 input_discovery,
1813 content_hashing,
1814 total: total_started.elapsed(),
1815 };
1816 let resolved_count = resolved_inputs
1817 .iter()
1818 .filter(|(_, result)| result.is_ok())
1819 .count();
1820 self.metrics.runs += usize::from(resolved_count > 0);
1821 self.metrics.reuses += resolved_count.saturating_sub(1);
1822 let timing_index = resolved_inputs
1823 .iter()
1824 .find(|(index, result)| *index == active_index && result.is_ok())
1825 .or_else(|| resolved_inputs.iter().find(|(_, result)| result.is_ok()))
1826 .map(|(index, _)| *index);
1827 for (index, result) in resolved_inputs {
1828 let (inputs, exclusions, input_digest, validation_digest) = match result {
1829 Ok(resolved) => resolved,
1830 Err(error) => {
1831 self.resolved[index] = Some(Err((WasmBuildPhase::ContentHashing, error)));
1832 continue;
1833 }
1834 };
1835 let spec = self.specs[index];
1836 let resolved = ResolvedCargoBuildInputs::assemble(
1837 spec,
1838 inputs,
1839 exclusions,
1840 input_digest,
1841 validation_digest,
1842 CargoBuildFacts::observed(spec, cargo_identity.clone(), rustc_identity.clone()),
1843 || {
1844 if Some(index) == timing_index {
1845 timings
1846 } else {
1847 WasmInputResolutionTimings::default()
1848 }
1849 },
1850 );
1851 self.resolved[index] = Some(Ok(resolved));
1852 }
1853 Ok(())
1854 }
1855
1856 fn discover_group_inputs(
1857 &mut self,
1858 group: usize,
1859 metadata: &schema::Metadata,
1860 progress: &mut ProgressReporter<'_>,
1861 ) -> (Vec<(usize, ResolvedLocalInputs, Vec<PathBuf>)>, Duration) {
1862 let started = Instant::now();
1863 let indexes = &self.groups[group].indexes;
1864 let specs = &self.specs;
1865 let resolved = &mut self.resolved;
1866 let discovered = progress.run_phase(WasmBuildPhase::InputDiscovery, || {
1867 let parsed = ParsedCargoMetadata::parse(metadata);
1868 let mut discovered = Vec::new();
1869 for &index in indexes {
1870 if resolved[index].is_some() || validate_spec(specs[index]).is_err() {
1871 continue;
1872 }
1873 let result = parsed
1874 .as_ref()
1875 .map_err(|message| invalid_metadata(message))
1876 .and_then(|parsed| resolve_local_inputs(specs[index], parsed));
1877 match result {
1878 Ok((inputs, exclusions)) => discovered.push((index, inputs, exclusions)),
1879 Err(error) => {
1880 resolved[index] = Some(Err((WasmBuildPhase::InputDiscovery, error)));
1881 }
1882 }
1883 }
1884 discovered
1885 });
1886 (discovered, started.elapsed())
1887 }
1888}
1889
1890fn resolve_tool_identity(
1891 spec: &WasmBuildSpec,
1892 progress: &mut ProgressReporter<'_>,
1893) -> Result<(Vec<u8>, Vec<u8>, Duration), WasmBuildError> {
1894 resolve_tool_identity_with_policy(spec, progress, &mut None)
1895}
1896
1897fn resolve_tool_identity_with_policy(
1898 spec: &WasmBuildSpec,
1899 progress: &mut ProgressReporter<'_>,
1900 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
1901) -> Result<(Vec<u8>, Vec<u8>, Duration), WasmBuildError> {
1902 let started = Instant::now();
1903 let cargo_identity = progress.run_phase(WasmBuildPhase::CargoIdentity, || {
1904 command_identity_with_policy(
1905 spec,
1906 WasmBuildPhase::CargoIdentity,
1907 &spec.cargo_program,
1908 &["--version", "--verbose"],
1909 policy,
1910 )
1911 })?;
1912 let rustc_program = spec
1913 .environment
1914 .get(OsStr::new("RUSTC"))
1915 .unwrap_or(&spec.rustc_program);
1916 let rustc_identity = progress.run_phase(WasmBuildPhase::RustcIdentity, || {
1917 command_identity_with_policy(
1918 spec,
1919 WasmBuildPhase::RustcIdentity,
1920 rustc_program,
1921 &["-vV"],
1922 policy,
1923 )
1924 })?;
1925 Ok((cargo_identity, rustc_identity, started.elapsed()))
1926}
1927
1928impl<'a> BatchResolutionKey<'a> {
1929 fn for_spec(spec: &'a WasmBuildSpec) -> Self {
1930 Self {
1931 workspace_root: &spec.workspace_root,
1932 cargo_program: &spec.cargo_program,
1933 rustc_program: spec
1934 .environment
1935 .get(OsStr::new("RUSTC"))
1936 .unwrap_or(&spec.rustc_program),
1937 metadata_arguments: spec.cargo_selection.metadata_arguments(),
1938 environment: spec.environment.clone(),
1939 }
1940 }
1941}
1942
1943impl SharedIncrementalTargetInspection {
1944 #[must_use]
1946 pub fn target_dir(&self) -> &Path {
1947 &self.target_dir
1948 }
1949
1950 #[must_use]
1952 pub const fn logical_size_bytes(&self) -> u64 {
1953 self.logical_size_bytes
1954 }
1955
1956 #[must_use]
1958 pub const fn last_used(&self) -> SystemTime {
1959 self.last_used
1960 }
1961
1962 #[must_use]
1964 pub const fn lock_wait(&self) -> Duration {
1965 self.lock_wait
1966 }
1967}
1968
1969impl SharedIncrementalTargetMaintenanceConfig {
1970 #[must_use]
1972 pub const fn new(policy: CacheRetentionLimits, minimum_interval: Duration) -> Self {
1973 Self {
1974 policy,
1975 minimum_interval,
1976 failure_mode: SharedIncrementalTargetMaintenanceFailureMode::Strict,
1977 }
1978 }
1979
1980 #[must_use]
1982 pub const fn with_failure_mode(
1983 mut self,
1984 failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
1985 ) -> Self {
1986 self.failure_mode = failure_mode;
1987 self
1988 }
1989
1990 #[must_use]
1992 pub const fn policy(self) -> CacheRetentionLimits {
1993 self.policy
1994 }
1995
1996 #[must_use]
1998 pub const fn minimum_interval(self) -> Duration {
1999 self.minimum_interval
2000 }
2001
2002 #[must_use]
2004 pub const fn failure_mode(self) -> SharedIncrementalTargetMaintenanceFailureMode {
2005 self.failure_mode
2006 }
2007}
2008
2009impl SharedIncrementalTargetMaintenance {
2010 #[must_use]
2012 pub fn target_dir(&self) -> &Path {
2013 &self.target_dir
2014 }
2015
2016 #[must_use]
2018 pub const fn logical_size_bytes_before(&self) -> u64 {
2019 self.logical_size_bytes_before
2020 }
2021
2022 #[must_use]
2024 pub const fn logical_size_bytes_after(&self) -> u64 {
2025 self.logical_size_bytes_after
2026 }
2027
2028 #[must_use]
2030 pub const fn last_used_before(&self) -> SystemTime {
2031 self.last_used_before
2032 }
2033
2034 #[must_use]
2036 pub const fn was_cleared(&self) -> bool {
2037 self.cleared
2038 }
2039
2040 #[must_use]
2042 pub const fn lock_wait(&self) -> Duration {
2043 self.lock_wait
2044 }
2045
2046 #[must_use]
2048 pub const fn maintenance(&self) -> Duration {
2049 self.maintenance
2050 }
2051}
2052
2053impl std::fmt::Display for SharedIncrementalTargetMaintenance {
2054 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2055 write!(
2056 formatter,
2057 "target={} action={} bytes={}=>{} lock={:?} maintenance={:?}",
2058 self.target_dir.display(),
2059 if self.cleared { "cleared" } else { "retained" },
2060 self.logical_size_bytes_before,
2061 self.logical_size_bytes_after,
2062 self.lock_wait,
2063 self.maintenance,
2064 )
2065 }
2066}
2067
2068impl SharedIncrementalTargetMaintenanceOutcome {
2069 #[must_use]
2071 pub fn target_dir(&self) -> &Path {
2072 match self {
2073 Self::Missing { target_dir }
2074 | Self::Skipped { target_dir, .. }
2075 | Self::Failed { target_dir, .. } => target_dir,
2076 Self::Performed { maintenance, .. } => maintenance.target_dir(),
2077 }
2078 }
2079
2080 #[must_use]
2082 pub const fn maintenance(&self) -> Option<&SharedIncrementalTargetMaintenance> {
2083 match self {
2084 Self::Performed { maintenance, .. } => Some(maintenance),
2085 Self::Missing { .. } | Self::Skipped { .. } | Self::Failed { .. } => None,
2086 }
2087 }
2088
2089 #[must_use]
2091 pub const fn was_performed(&self) -> bool {
2092 matches!(self, Self::Performed { .. })
2093 }
2094
2095 #[must_use]
2097 pub const fn lock_wait(&self) -> Option<Duration> {
2098 match self {
2099 Self::Missing { .. } => None,
2100 Self::Skipped { lock_wait, .. } | Self::Failed { lock_wait, .. } => Some(*lock_wait),
2101 Self::Performed { maintenance, .. } => Some(maintenance.lock_wait()),
2102 }
2103 }
2104
2105 #[must_use]
2107 pub const fn schedule_check(&self) -> Option<Duration> {
2108 match self {
2109 Self::Missing { .. } | Self::Failed { .. } => None,
2110 Self::Skipped { schedule_check, .. } | Self::Performed { schedule_check, .. } => {
2111 Some(*schedule_check)
2112 }
2113 }
2114 }
2115
2116 #[must_use]
2118 pub fn failure_message(&self) -> Option<&str> {
2119 match self {
2120 Self::Failed { message, .. } => Some(message),
2121 Self::Missing { .. } | Self::Skipped { .. } | Self::Performed { .. } => None,
2122 }
2123 }
2124}
2125
2126impl std::fmt::Display for SharedIncrementalTargetMaintenanceOutcome {
2127 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2128 match self {
2129 Self::Missing { target_dir } => {
2130 write!(formatter, "target={} action=missing", target_dir.display())
2131 }
2132 Self::Skipped {
2133 target_dir,
2134 lock_wait,
2135 schedule_check,
2136 } => write!(
2137 formatter,
2138 "target={} action=skipped lock={lock_wait:?} schedule={schedule_check:?}",
2139 target_dir.display(),
2140 ),
2141 Self::Performed {
2142 maintenance,
2143 schedule_check,
2144 } => write!(formatter, "{maintenance} schedule={schedule_check:?}"),
2145 Self::Failed {
2146 target_dir,
2147 lock_wait,
2148 message,
2149 } => write!(
2150 formatter,
2151 "target={} action=failed lock={lock_wait:?} error={message}",
2152 target_dir.display(),
2153 ),
2154 }
2155 }
2156}
2157
2158impl std::fmt::Display for WasmBuildTimings {
2159 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2160 write!(
2161 formatter,
2162 "total={:?} lock={:?} shared_lock={:?} inputs={:?} cargo={:?} maintenance={:?}",
2163 self.total,
2164 self.lock_wait,
2165 self.shared_incremental_lock_wait,
2166 self.input_resolution.total,
2167 self.cargo_build,
2168 self.cache_maintenance,
2169 )
2170 }
2171}
2172
2173impl std::fmt::Display for WasmBuildOutcome {
2174 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2175 let state = if self.is_reused() { "reused" } else { "built" };
2176 write!(
2177 formatter,
2178 "{state} fingerprint={} artifacts={} {}",
2179 self.record().fingerprint,
2180 self.record().artifacts.len(),
2181 self.record().timings,
2182 )?;
2183 if let Some(maintenance) = self.record().shared_incremental_maintenance() {
2184 write!(formatter, " shared_maintenance=({maintenance})")?;
2185 }
2186 Ok(())
2187 }
2188}
2189
2190pub fn resolve_cargo_build_inputs(
2204 spec: &WasmBuildSpec,
2205) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2206 validate_spec(spec)?;
2207 build_fingerprint(spec)
2208}
2209
2210pub fn resolve_cargo_build_inputs_with_probe_policy(
2222 spec: &WasmBuildSpec,
2223 policy: &mut CargoInputProbePolicy<'_>,
2224) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2225 validate_spec(spec)?;
2226 build_fingerprint_with_policy(spec, &mut ProgressReporter::silent(), &mut Some(policy))
2227}
2228
2229pub fn inspect_shared_incremental_target(
2237 spec: &WasmBuildSpec,
2238) -> Result<Option<SharedIncrementalTargetInspection>, WasmBuildError> {
2239 if !shared_incremental_target_exists(spec, "inspect shared incremental Cargo target")? {
2240 return Ok(None);
2241 }
2242
2243 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2244 let logical_size_bytes =
2245 directory_logical_size(&canonical).map_err(|source| WasmBuildError::Io {
2246 operation: "measure shared incremental Cargo target",
2247 path: canonical.clone(),
2248 source,
2249 })?;
2250 let last_used = cache_entry_last_used(&canonical).map_err(|source| WasmBuildError::Io {
2251 operation: "read shared incremental Cargo target use time",
2252 path: canonical.clone(),
2253 source,
2254 })?;
2255 Ok(Some(SharedIncrementalTargetInspection {
2256 target_dir: canonical,
2257 logical_size_bytes,
2258 last_used,
2259 lock_wait,
2260 }))
2261}
2262
2263pub fn maintain_shared_incremental_target(
2281 spec: &WasmBuildSpec,
2282 policy: CacheRetentionLimits,
2283) -> Result<Option<SharedIncrementalTargetMaintenance>, WasmBuildError> {
2284 if !shared_incremental_target_exists(
2285 spec,
2286 "inspect shared incremental Cargo target before maintenance",
2287 )? {
2288 return Ok(None);
2289 }
2290
2291 let _ = resolve_cargo_build_inputs(spec)?;
2295 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2296 maintain_shared_incremental_target_locked(&canonical, policy, lock_wait).map(Some)
2297}
2298
2299pub fn maintain_shared_incremental_target_at_most_every(
2317 spec: &WasmBuildSpec,
2318 policy: CacheRetentionLimits,
2319 minimum_interval: Duration,
2320) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2321 let target_dir =
2322 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
2323 message: "shared incremental target is not configured".to_owned(),
2324 })?;
2325 if !shared_incremental_target_exists(
2326 spec,
2327 "inspect shared incremental Cargo target before scheduled maintenance",
2328 )? {
2329 return Ok(SharedIncrementalTargetMaintenanceOutcome::Missing { target_dir });
2330 }
2331
2332 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2333 let schedule = schedule_shared_incremental_target_maintenance(
2334 &canonical,
2335 policy,
2336 minimum_interval,
2337 lock_wait,
2338 )?;
2339 let schedule = match schedule {
2340 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => return Ok(outcome),
2341 SharedIncrementalTargetMaintenanceSchedule::Due(due) => due,
2342 };
2343
2344 let _ = resolve_cargo_build_inputs(spec)?;
2347 perform_due_shared_incremental_target_maintenance(&canonical, policy, lock_wait, schedule)
2348}
2349
2350enum SharedIncrementalTargetMaintenanceSchedule {
2351 Skipped(SharedIncrementalTargetMaintenanceOutcome),
2352 Due(DueSharedIncrementalTargetMaintenance),
2353}
2354
2355struct DueSharedIncrementalTargetMaintenance {
2356 schedule_root: PathBuf,
2357 maintenance_identity: String,
2358 schedule_check: Duration,
2359}
2360
2361fn schedule_shared_incremental_target_maintenance(
2362 canonical: &Path,
2363 policy: CacheRetentionLimits,
2364 minimum_interval: Duration,
2365 lock_wait: Duration,
2366) -> Result<SharedIncrementalTargetMaintenanceSchedule, WasmBuildError> {
2367 let schedule_root = canonical.join(".ic-testkit");
2368 let maintenance_identity = policy.maintenance_identity();
2369 let schedule_started = Instant::now();
2370 let due = cache_maintenance_due(&schedule_root, minimum_interval, &maintenance_identity)
2371 .map_err(wasm_cache_fs_error)?;
2372 let schedule_check = schedule_started.elapsed();
2373 if !due {
2374 return Ok(SharedIncrementalTargetMaintenanceSchedule::Skipped(
2375 SharedIncrementalTargetMaintenanceOutcome::Skipped {
2376 target_dir: canonical.to_owned(),
2377 lock_wait,
2378 schedule_check,
2379 },
2380 ));
2381 }
2382 Ok(SharedIncrementalTargetMaintenanceSchedule::Due(
2383 DueSharedIncrementalTargetMaintenance {
2384 schedule_root,
2385 maintenance_identity,
2386 schedule_check,
2387 },
2388 ))
2389}
2390
2391fn perform_due_shared_incremental_target_maintenance(
2392 canonical: &Path,
2393 policy: CacheRetentionLimits,
2394 lock_wait: Duration,
2395 due: DueSharedIncrementalTargetMaintenance,
2396) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2397 let DueSharedIncrementalTargetMaintenance {
2398 schedule_root,
2399 maintenance_identity,
2400 schedule_check,
2401 } = due;
2402 let maintenance = maintain_shared_incremental_target_locked(canonical, policy, lock_wait)?;
2403 record_cache_maintenance(&schedule_root, &maintenance_identity).map_err(wasm_cache_fs_error)?;
2404 Ok(SharedIncrementalTargetMaintenanceOutcome::Performed {
2405 maintenance,
2406 schedule_check,
2407 })
2408}
2409
2410fn maintain_shared_incremental_target_locked(
2411 canonical: &Path,
2412 policy: CacheRetentionLimits,
2413 lock_wait: Duration,
2414) -> Result<SharedIncrementalTargetMaintenance, WasmBuildError> {
2415 let started = Instant::now();
2416 let logical_size_bytes_before =
2417 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2418 operation: "measure shared incremental Cargo target before maintenance",
2419 path: canonical.to_owned(),
2420 source,
2421 })?;
2422 let last_used_before =
2423 cache_entry_last_used(canonical).map_err(|source| WasmBuildError::Io {
2424 operation: "read shared incremental Cargo target use time before maintenance",
2425 path: canonical.to_owned(),
2426 source,
2427 })?;
2428 let expired = policy.max_age().is_some_and(|max_age| {
2429 SystemTime::now()
2430 .duration_since(last_used_before)
2431 .is_ok_and(|age| age > max_age)
2432 });
2433 let oversized = policy
2434 .max_size_bytes()
2435 .is_some_and(|max_size_bytes| logical_size_bytes_before > max_size_bytes);
2436 let cleared = expired || oversized;
2437 if cleared {
2438 clear_shared_incremental_target_contents(canonical)?;
2439 record_cache_entry_use(canonical)?;
2440 }
2441 let logical_size_bytes_after = if cleared {
2442 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2443 operation: "measure shared incremental Cargo target after maintenance",
2444 path: canonical.to_owned(),
2445 source,
2446 })?
2447 } else {
2448 logical_size_bytes_before
2449 };
2450 Ok(SharedIncrementalTargetMaintenance {
2451 target_dir: canonical.to_owned(),
2452 logical_size_bytes_before,
2453 logical_size_bytes_after,
2454 last_used_before,
2455 cleared,
2456 lock_wait,
2457 maintenance: started.elapsed(),
2458 })
2459}
2460
2461fn clear_shared_incremental_target_contents(target_dir: &Path) -> Result<(), WasmBuildError> {
2462 let entries = fs::read_dir(target_dir).map_err(|source| WasmBuildError::Io {
2463 operation: "read shared incremental Cargo target for maintenance",
2464 path: target_dir.to_owned(),
2465 source,
2466 })?;
2467 for entry in entries {
2468 let path = entry
2469 .map_err(|source| WasmBuildError::Io {
2470 operation: "read shared incremental Cargo target entry for maintenance",
2471 path: target_dir.to_owned(),
2472 source,
2473 })?
2474 .path();
2475 let preserved = path
2476 .file_name()
2477 .is_some_and(|name| name == ".ic-testkit" || name == "CACHEDIR.TAG");
2478 if !preserved {
2479 remove_path_if_present(&path).map_err(|source| WasmBuildError::Io {
2480 operation: "clear shared incremental Cargo target entry",
2481 path,
2482 source,
2483 })?;
2484 }
2485 }
2486 Ok(())
2487}
2488
2489pub fn build_wasm_canisters_cached(
2520 spec: &WasmBuildSpec,
2521 mut spawn: impl FnMut(&mut Command) -> io::Result<OwnedChild>,
2522) -> Result<WasmBuildOutcome, WasmBuildError> {
2523 build_wasm_canisters_cached_internal(spec, &mut ProgressReporter::silent(), None, &mut spawn)
2524}
2525
2526pub fn build_wasm_canisters_cached_with_progress<F>(
2535 spec: &WasmBuildSpec,
2536 config: WasmBuildProgressConfig,
2537 mut observer: F,
2538 mut spawn: impl FnMut(&mut Command) -> io::Result<OwnedChild>,
2539) -> Result<WasmBuildOutcome, WasmBuildError>
2540where
2541 F: FnMut(WasmBuildProgressEvent),
2542{
2543 build_wasm_canisters_cached_internal(
2544 spec,
2545 &mut ProgressReporter::new(Some((config, &mut observer)))?,
2546 None,
2547 &mut spawn,
2548 )
2549}
2550
2551#[expect(
2552 clippy::result_large_err,
2553 reason = "Batch entries retain complete failure details inline without per-failure allocation."
2554)]
2555pub(super) fn build_wasm_canisters_cached_in_batch(
2556 spec: &WasmBuildSpec,
2557 index: usize,
2558 resolver: &mut WasmBuildBatchInputResolver<'_, '_>,
2559 observation: Option<(
2560 WasmBuildProgressConfig,
2561 &mut dyn FnMut(WasmBuildProgressEvent),
2562 )>,
2563 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
2564) -> Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)> {
2565 let mut progress = ProgressReporter::new(observation).map_err(|error| {
2566 (
2567 error,
2568 WasmBuildFailureDetails::specification(Duration::ZERO),
2569 )
2570 })?;
2571 let started = Instant::now();
2572 let result = build_wasm_canisters_cached_internal(
2573 spec,
2574 &mut progress,
2575 Some((resolver, index)),
2576 &mut spawn,
2577 );
2578 batch_result(result, &progress, resolver, started)
2579}
2580
2581#[expect(
2582 clippy::result_large_err,
2583 reason = "Batch entries retain complete failure details inline without per-failure allocation."
2584)]
2585fn batch_result(
2586 result: Result<WasmBuildOutcome, WasmBuildError>,
2587 progress: &ProgressReporter<'_>,
2588 resolver: &WasmBuildBatchInputResolver<'_, '_>,
2589 started: Instant,
2590) -> Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)> {
2591 if result
2592 .as_ref()
2593 .is_err_and(WasmBuildError::indicates_input_change)
2594 {
2595 resolver.invalidate_source_lease();
2596 }
2597 let total = started.elapsed();
2598 result.map_err(|error| {
2599 let details = progress.failure_details(&error, total);
2600 (error, details)
2601 })
2602}
2603
2604fn batch_prepared_invalidation(
2605 batch_resolution: Option<&(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2606) -> Option<Arc<RwLock<bool>>> {
2607 batch_resolution.and_then(|(resolver, _)| resolver.prepared_invalidation())
2608}
2609
2610fn build_wasm_canisters_cached_internal(
2611 spec: &WasmBuildSpec,
2612 progress: &mut ProgressReporter<'_>,
2613 mut batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2614 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
2615) -> Result<WasmBuildOutcome, WasmBuildError> {
2616 let total_started = Instant::now();
2617 validate_spec(spec)?;
2618 let prepared_invalidation = batch_prepared_invalidation(batch_resolution.as_ref());
2619 progress.emit(WasmBuildProgressEvent::Started);
2620 if spec.shared_incremental_maintenance_config.is_some() {
2621 let outcome = build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2622 spec,
2623 total_started,
2624 progress,
2625 batch_resolution.take(),
2626 &mut spawn,
2627 )?;
2628 emit_finished_progress(&outcome, progress);
2629 return Ok(outcome);
2630 }
2631 let (cache_lock, first_lock_wait) =
2632 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2633 ensure_cache_directory_tag(&spec.target_dir)?;
2634
2635 let resolved = resolve_initial_inputs(spec, batch_resolution.take(), progress)?;
2636 let isolated_acquisition = WasmAcquisitionContext::isolated(prepared_invalidation);
2637 if let Some(outcome) = try_reuse_wasm_artifacts(
2638 spec,
2639 &resolved,
2640 first_lock_wait,
2641 &isolated_acquisition,
2642 total_started,
2643 progress,
2644 )? {
2645 emit_finished_progress(&outcome, progress);
2646 return Ok(outcome);
2647 }
2648 progress.emit(WasmBuildProgressEvent::CacheMiss {
2649 fingerprint: resolved.fingerprint,
2650 });
2651
2652 let outcome = match &spec.cache_mode {
2653 WasmBuildCacheMode::Isolated => {
2654 let cache_entry = cache_entry_directory(spec, resolved.fingerprint);
2655 build_wasm_cache_miss(
2656 spec,
2657 &resolved,
2658 first_lock_wait,
2659 isolated_acquisition,
2660 &cache_entry,
2661 total_started,
2662 progress,
2663 &mut spawn,
2664 )
2665 }
2666 WasmBuildCacheMode::SharedIncremental { .. } => {
2667 drop(cache_lock);
2668 build_wasm_with_shared_incremental(
2669 spec,
2670 resolved,
2671 first_lock_wait,
2672 isolated_acquisition,
2673 total_started,
2674 progress,
2675 &mut spawn,
2676 )
2677 }
2678 }?;
2679 emit_finished_progress(&outcome, progress);
2680 Ok(outcome)
2681}
2682
2683fn build_wasm_with_shared_incremental(
2684 spec: &WasmBuildSpec,
2685 resolved: ResolvedCargoBuildInputs,
2686 first_lock_wait: Duration,
2687 source: WasmAcquisitionContext,
2688 total_started: Instant,
2689 progress: &mut ProgressReporter<'_>,
2690 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
2691) -> Result<WasmBuildOutcome, WasmBuildError> {
2692 let (shared_lock, shared_lock_wait, shared_target) =
2693 lock_shared_incremental_target_with_progress(spec, progress)?;
2694 let (_cache_lock, second_lock_wait) =
2695 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2696 ensure_cache_directory_tag(&spec.target_dir)?;
2697
2698 let current = if source.prepared_invalidation.is_some() {
2699 resolved
2700 } else {
2701 let mut current = resolve_initial_inputs(spec, None, progress)?;
2702 current.timings.include(resolved.timings);
2703 current
2704 };
2705 let lock_wait = first_lock_wait.saturating_add(second_lock_wait);
2706 let shared_incremental =
2707 WasmAcquisitionContext::shared(shared_lock_wait, None, source.prepared_invalidation);
2708 if let Some(outcome) = try_reuse_wasm_artifacts(
2709 spec,
2710 ¤t,
2711 lock_wait,
2712 &shared_incremental,
2713 total_started,
2714 progress,
2715 )? {
2716 return Ok(outcome);
2717 }
2718
2719 let outcome = build_wasm_cache_miss(
2720 spec,
2721 ¤t,
2722 lock_wait,
2723 shared_incremental,
2724 &shared_target,
2725 total_started,
2726 progress,
2727 &mut spawn,
2728 );
2729 drop(shared_lock);
2730 outcome
2731}
2732
2733fn build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2734 spec: &WasmBuildSpec,
2735 total_started: Instant,
2736 progress: &mut ProgressReporter<'_>,
2737 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2738 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
2739) -> Result<WasmBuildOutcome, WasmBuildError> {
2740 let prepared_invalidation = batch_prepared_invalidation(batch_resolution.as_ref());
2741 let (_shared_lock, shared_lock_wait, shared_target) =
2742 lock_shared_incremental_target_with_progress(spec, progress)?;
2743 let (_cache_lock, lock_wait) = lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2744 ensure_cache_directory_tag(&spec.target_dir)?;
2745
2746 let resolved = resolve_initial_inputs(spec, batch_resolution, progress)?;
2749 let shared_maintenance = perform_configured_shared_incremental_target_maintenance(
2750 spec,
2751 &shared_target,
2752 shared_lock_wait,
2753 progress,
2754 )?;
2755 let shared_incremental = WasmAcquisitionContext::shared(
2756 shared_lock_wait,
2757 Some(shared_maintenance),
2758 prepared_invalidation,
2759 );
2760 if let Some(outcome) = try_reuse_wasm_artifacts(
2761 spec,
2762 &resolved,
2763 lock_wait,
2764 &shared_incremental,
2765 total_started,
2766 progress,
2767 )? {
2768 return Ok(outcome);
2769 }
2770 progress.emit(WasmBuildProgressEvent::CacheMiss {
2771 fingerprint: resolved.fingerprint,
2772 });
2773 build_wasm_cache_miss(
2774 spec,
2775 &resolved,
2776 lock_wait,
2777 shared_incremental,
2778 &shared_target,
2779 total_started,
2780 progress,
2781 &mut spawn,
2782 )
2783}
2784
2785fn perform_configured_shared_incremental_target_maintenance(
2786 spec: &WasmBuildSpec,
2787 shared_target: &Path,
2788 lock_wait: Duration,
2789 progress: &mut ProgressReporter<'_>,
2790) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2791 let config = spec
2792 .shared_incremental_maintenance_config
2793 .expect("configured shared-target maintenance must have settings");
2794 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceStarted {
2795 target_dir: shared_target.to_owned(),
2796 });
2797 let result = progress.run_phase(WasmBuildPhase::SharedTargetMaintenance, || {
2798 let schedule = schedule_shared_incremental_target_maintenance(
2799 shared_target,
2800 config.policy,
2801 config.minimum_interval,
2802 lock_wait,
2803 )?;
2804 match schedule {
2805 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => Ok(outcome),
2806 SharedIncrementalTargetMaintenanceSchedule::Due(due) => {
2807 perform_due_shared_incremental_target_maintenance(
2808 shared_target,
2809 config.policy,
2810 lock_wait,
2811 due,
2812 )
2813 }
2814 }
2815 });
2816 let outcome = integrated_shared_maintenance_result(config, shared_target, lock_wait, result)?;
2817 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceFinished {
2818 outcome: outcome.clone(),
2819 });
2820 Ok(outcome)
2821}
2822
2823fn integrated_shared_maintenance_result(
2824 config: SharedIncrementalTargetMaintenanceConfig,
2825 shared_target: &Path,
2826 lock_wait: Duration,
2827 result: Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError>,
2828) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2829 match result {
2830 Ok(outcome) => Ok(outcome),
2831 Err(error)
2832 if config.failure_mode == SharedIncrementalTargetMaintenanceFailureMode::BestEffort =>
2833 {
2834 Ok(SharedIncrementalTargetMaintenanceOutcome::Failed {
2835 target_dir: shared_target.to_owned(),
2836 lock_wait,
2837 message: error.to_string(),
2838 })
2839 }
2840 Err(error) => Err(error),
2841 }
2842}
2843
2844fn resolve_initial_inputs(
2845 spec: &WasmBuildSpec,
2846 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2847 progress: &mut ProgressReporter<'_>,
2848) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2849 let resolved = if let Some((resolver, index)) = batch_resolution {
2850 resolver.resolve(index, progress)?
2851 } else {
2852 build_fingerprint_with_progress(spec, progress)?
2853 };
2854 resolved.wasm_outputs().inspect_err(|_| {
2855 progress.begin_phase(WasmBuildPhase::InputDiscovery);
2856 })?;
2857 progress.emit(WasmBuildProgressEvent::InputsResolved {
2858 fingerprint: resolved.fingerprint,
2859 input_digest: resolved.input_digest,
2860 elapsed: resolved.timings.total,
2861 });
2862 Ok(resolved)
2863}
2864
2865fn emit_finished_progress(outcome: &WasmBuildOutcome, progress: &mut ProgressReporter<'_>) {
2866 let state = if outcome.is_reused() {
2867 progress.emit(WasmBuildProgressEvent::CacheHit {
2868 fingerprint: outcome.record().fingerprint,
2869 });
2870 WasmBuildProgressOutcome::Reused
2871 } else {
2872 WasmBuildProgressOutcome::Built
2873 };
2874 progress.emit(WasmBuildProgressEvent::Finished {
2875 outcome: state,
2876 fingerprint: outcome.record().fingerprint,
2877 elapsed: outcome.record().timings.total,
2878 });
2879}
2880
2881#[derive(Clone, Debug, Default)]
2882struct WasmAcquisitionContext {
2883 lock_wait: Option<Duration>,
2884 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
2885 prepared_invalidation: Option<Arc<RwLock<bool>>>,
2886}
2887
2888impl WasmAcquisitionContext {
2889 fn isolated(prepared_invalidation: Option<Arc<RwLock<bool>>>) -> Self {
2890 Self {
2891 prepared_invalidation,
2892 ..Self::default()
2893 }
2894 }
2895
2896 const fn shared(
2897 lock_wait: Duration,
2898 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
2899 prepared_invalidation: Option<Arc<RwLock<bool>>>,
2900 ) -> Self {
2901 Self {
2902 lock_wait: Some(lock_wait),
2903 maintenance,
2904 prepared_invalidation,
2905 }
2906 }
2907
2908 fn lock_prepared_publication(
2909 &self,
2910 ) -> Result<Option<std::sync::RwLockReadGuard<'_, bool>>, WasmBuildError> {
2911 let guard = self.prepared_invalidation.as_deref().map(|invalidation| {
2912 invalidation
2913 .read()
2914 .unwrap_or_else(std::sync::PoisonError::into_inner)
2915 });
2916 if guard.as_deref().is_some_and(|invalidated| *invalidated) {
2917 return Err(WasmBuildError::PreparedInputSnapshotInvalidated);
2918 }
2919 Ok(guard)
2920 }
2921}
2922
2923fn try_reuse_wasm_artifacts(
2924 spec: &WasmBuildSpec,
2925 resolved: &ResolvedCargoBuildInputs,
2926 lock_wait: Duration,
2927 shared_incremental: &WasmAcquisitionContext,
2928 total_started: Instant,
2929 progress: &mut ProgressReporter<'_>,
2930) -> Result<Option<WasmBuildOutcome>, WasmBuildError> {
2931 let targets = &resolved.wasm_outputs()?.targets;
2932 let _publication_guard = shared_incremental.lock_prepared_publication()?;
2933 let fingerprint = resolved.fingerprint;
2934 let cache_entry = cache_entry_directory(spec, fingerprint);
2935 let paths = artifact_publication_paths(spec, &cache_entry, targets);
2936 let Some(artifact_info) = progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
2937 validated_artifact_set(paths, fingerprint)
2938 }) else {
2939 return Ok(None);
2940 };
2941 progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
2942 materialize_artifacts(&artifact_info, fingerprint, true)?;
2943 record_cache_entry_use(&cache_entry)
2944 })?;
2945 let input_resolution = validate_reused_inputs(spec, resolved, shared_incremental, progress)?;
2946 Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
2947 spec,
2948 BuildRecordInput {
2949 resolved,
2950 lock_wait,
2951 shared_incremental: shared_incremental.clone(),
2952 input_resolution,
2953 cargo_build: None,
2954 active_entry: &cache_entry,
2955 targets,
2956 },
2957 total_started,
2958 progress,
2959 )?)))
2960}
2961
2962fn validate_reused_inputs(
2963 spec: &WasmBuildSpec,
2964 resolved: &ResolvedCargoBuildInputs,
2965 context: &WasmAcquisitionContext,
2966 progress: &mut ProgressReporter<'_>,
2967) -> Result<WasmInputResolutionTimings, WasmBuildError> {
2968 let mut timings = resolved.timings;
2969 if context.prepared_invalidation.is_none() {
2970 let verified = verify_resolved_inputs(spec, resolved, progress)?;
2971 timings.include(verified.timings);
2972 }
2973 Ok(timings)
2974}
2975
2976fn verify_resolved_inputs(
2977 spec: &WasmBuildSpec,
2978 resolved: &ResolvedCargoBuildInputs,
2979 progress: &mut ProgressReporter<'_>,
2980) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2981 let verified = resolve_initial_inputs(spec, None, progress)?;
2982 for (before, after) in [
2983 (resolved.validation_digest, verified.validation_digest),
2984 (resolved.fingerprint, verified.fingerprint),
2985 ] {
2986 if before != after {
2987 return Err(WasmBuildError::InputsChangedDuringAcquisition { before, after });
2988 }
2989 }
2990 Ok(verified)
2991}
2992
2993#[expect(
2994 clippy::too_many_arguments,
2995 reason = "keep the caller's spawner separate from recipe, acquisition and progress state"
2996)]
2997fn build_wasm_cache_miss(
2998 spec: &WasmBuildSpec,
2999 resolved: &ResolvedCargoBuildInputs,
3000 lock_wait: Duration,
3001 shared_incremental: WasmAcquisitionContext,
3002 cargo_target_dir: &Path,
3003 total_started: Instant,
3004 progress: &mut ProgressReporter<'_>,
3005 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
3006) -> Result<WasmBuildOutcome, WasmBuildError> {
3007 let targets = &resolved.wasm_outputs()?.targets;
3008 let fingerprint = resolved.fingerprint;
3009 let mut input_resolution = resolved.timings;
3010 let cache_entry = cache_entry_directory(spec, fingerprint);
3011 let preparation_started = Instant::now();
3012 progress.begin_phase(WasmBuildPhase::ArtifactPublication);
3013 let preparation_result = prepare_exact_cache_entry(&cache_entry);
3014 progress.record_phase(
3015 WasmBuildPhase::ArtifactPublication,
3016 preparation_started.elapsed(),
3017 );
3018 let incomplete_directory = preparation_result?;
3019 let build_result = (|| {
3020 if matches!(
3021 spec.cache_mode,
3022 WasmBuildCacheMode::SharedIncremental { .. }
3023 ) {
3024 record_cache_entry_use(cargo_target_dir)?;
3025 }
3026 let build_started = Instant::now();
3027 progress.begin_phase(WasmBuildPhase::CargoBuild);
3028 let cargo_result = run_cargo_build(spec, cargo_target_dir, targets, progress, &mut spawn);
3029 let cargo_build = build_started.elapsed();
3030 progress.record_phase(WasmBuildPhase::CargoBuild, cargo_build);
3031 let built_artifacts = cargo_result?;
3032 let validation_started = Instant::now();
3033 progress.begin_phase(WasmBuildPhase::ArtifactPublication);
3034 let missing = missing_artifacts(&built_artifacts);
3035 progress.record_phase(
3036 WasmBuildPhase::ArtifactPublication,
3037 validation_started.elapsed(),
3038 );
3039 if !missing.is_empty() {
3040 return Err(WasmBuildError::MissingArtifacts { paths: missing });
3041 }
3042
3043 let verified = verify_resolved_inputs(spec, resolved, progress)?;
3044 input_resolution.include(verified.timings);
3045
3046 let publication_guard = shared_incremental.lock_prepared_publication()?;
3051
3052 let paths = artifact_publication_paths(spec, &cache_entry, targets);
3053 progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
3054 copy_wasm_artifacts(&built_artifacts, &paths)?;
3055 let artifacts = publish_artifact_stamps(paths, fingerprint)?;
3056 materialize_artifacts(&artifacts, fingerprint, false)?;
3057 record_cache_entry_use(&cache_entry)
3058 })?;
3059 drop(publication_guard);
3060
3061 Ok(WasmBuildOutcome::Built(complete_build_record(
3062 spec,
3063 BuildRecordInput {
3064 resolved,
3065 lock_wait,
3066 shared_incremental,
3067 input_resolution,
3068 cargo_build: Some(cargo_build),
3069 active_entry: &cache_entry,
3070 targets,
3071 },
3072 total_started,
3073 progress,
3074 )?))
3075 })();
3076 finish_fingerprint_build(build_result, incomplete_directory, progress)
3077}
3078
3079pub fn prune_wasm_build_cache(
3091 target_dir: &Path,
3092 policy: CacheRetentionLimits,
3093) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3094 let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
3095 ensure_cache_directory_tag(target_dir)?;
3096
3097 prune_wasm_build_cache_locked(target_dir, policy, None)
3098}
3099
3100struct BuildRecordInput<'a> {
3101 resolved: &'a ResolvedCargoBuildInputs,
3102 lock_wait: Duration,
3103 shared_incremental: WasmAcquisitionContext,
3104 input_resolution: WasmInputResolutionTimings,
3105 cargo_build: Option<Duration>,
3106 active_entry: &'a Path,
3107 targets: &'a [WasmArtifactTarget],
3108}
3109
3110fn complete_build_record(
3111 spec: &WasmBuildSpec,
3112 input: BuildRecordInput<'_>,
3113 total_started: Instant,
3114 progress: &mut ProgressReporter<'_>,
3115) -> Result<WasmBuildRecord, WasmBuildError> {
3116 let retention = progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
3117 RetainedCacheEntry::acquire(input.active_entry).map_err(wasm_cache_fs_error)
3118 })?;
3119 let (maintenance, cache_maintenance) =
3120 spec.pruning.map_or((None, None), |(policy, interval)| {
3121 progress.run_phase(WasmBuildPhase::ExactCacheMaintenance, || {
3122 let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
3123 let identity = policy.maintenance_identity();
3124 perform_scheduled_cache_maintenance(&cache_root, interval, &identity, || {
3125 prune_wasm_build_cache_locked(
3126 &spec.target_dir,
3127 policy,
3128 Some(input.active_entry),
3129 )
3130 .map_err(|error| error.to_string())
3131 })
3132 })
3133 });
3134 Ok(WasmBuildRecord {
3135 facts: input.resolved.facts.clone(),
3136 fingerprint: input.resolved.fingerprint,
3137 input_digest: input.resolved.input_digest,
3138 artifacts: expected_artifacts(spec, input.active_entry, input.targets).into(),
3139 retention,
3140 timings: WasmBuildTimings {
3141 lock_wait: input.lock_wait,
3142 shared_incremental_lock_wait: input.shared_incremental.lock_wait,
3143 input_resolution: input.input_resolution,
3144 cargo_build: input.cargo_build,
3145 cache_maintenance,
3146 total: total_started.elapsed(),
3147 },
3148 maintenance,
3149 shared_incremental_maintenance: input.shared_incremental.maintenance,
3150 })
3151}
3152
3153fn prune_wasm_build_cache_locked(
3154 target_dir: &Path,
3155 policy: CacheRetentionLimits,
3156 protected_entry: Option<&Path>,
3157) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3158 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
3159 prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
3160 .map_err(wasm_cache_fs_error)
3161}
3162
3163fn finish_fingerprint_build<T>(
3164 result: Result<T, WasmBuildError>,
3165 incomplete_directory: OwnedDirectory,
3166 progress: &mut ProgressReporter<'_>,
3167) -> Result<T, WasmBuildError> {
3168 match result {
3169 Ok(outcome) => {
3170 incomplete_directory.preserve();
3171 Ok(outcome)
3172 }
3173 Err(build_error) => Err(cleanup_failed_fingerprint_build(
3174 build_error,
3175 incomplete_directory,
3176 progress,
3177 )),
3178 }
3179}
3180
3181fn cleanup_failed_fingerprint_build(
3182 build_error: WasmBuildError,
3183 incomplete_directory: OwnedDirectory,
3184 progress: &mut ProgressReporter<'_>,
3185) -> WasmBuildError {
3186 let path = incomplete_directory.path().to_owned();
3187 let primary_phase = progress.failure_phase;
3188 let cleanup_started = Instant::now();
3189 let cleanup = incomplete_directory.cleanup();
3190 progress.record_phase(WasmBuildPhase::Cleanup, cleanup_started.elapsed());
3191 match cleanup {
3192 Ok(()) => {
3193 progress.failure_phase = primary_phase;
3194 build_error
3195 }
3196 Err(source) => WasmBuildError::FailedBuildCleanup {
3197 build_error: Box::new(build_error),
3198 path,
3199 source,
3200 },
3201 }
3202}
3203
3204fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
3205 create_dir_all(target_dir, "create Cargo target directory")?;
3206 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3207 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)
3208}
3209
3210fn lock_wasm_build_cache_with_progress(
3211 target_dir: &Path,
3212 progress: &mut ProgressReporter<'_>,
3213) -> Result<(File, Duration), WasmBuildError> {
3214 progress.begin_phase(WasmBuildPhase::ExactCacheCoordination);
3215 create_dir_all(target_dir, "create Cargo target directory")?;
3216 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3217 lock_cache_file_with_progress(&lock_path, WasmBuildPhase::ExactCacheCoordination, progress)
3218}
3219
3220fn lock_shared_incremental_target(
3221 spec: &WasmBuildSpec,
3222) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3223 lock_shared_incremental_target_internal(spec, None)
3224}
3225
3226fn lock_shared_incremental_target_with_progress(
3227 spec: &WasmBuildSpec,
3228 progress: &mut ProgressReporter<'_>,
3229) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3230 let target_dir = shared_incremental_target(spec)
3231 .expect("validated shared acquisition must have a shared Cargo target");
3232 progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted { target_dir });
3233 let (lock, wait, canonical) = lock_shared_incremental_target_internal(spec, Some(progress))?;
3234 progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
3235 target_dir: canonical.clone(),
3236 wait,
3237 });
3238 Ok((lock, wait, canonical))
3239}
3240
3241fn lock_shared_incremental_target_internal(
3242 spec: &WasmBuildSpec,
3243 mut progress: Option<&mut ProgressReporter<'_>>,
3244) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3245 if let Some(progress) = progress.as_deref_mut() {
3246 progress.begin_phase(WasmBuildPhase::SharedTargetCoordination);
3247 }
3248 let target_dir =
3249 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
3250 message: "shared incremental target is not configured".to_owned(),
3251 })?;
3252 create_dir_all(
3253 &target_dir,
3254 "create shared incremental Cargo target directory",
3255 )?;
3256 ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
3257 let canonical = target_dir
3258 .canonicalize()
3259 .map_err(|source| WasmBuildError::Io {
3260 operation: "resolve shared incremental Cargo target directory",
3261 path: target_dir.clone(),
3262 source,
3263 })?;
3264 let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
3265 let (lock, wait) = if let Some(progress) = progress {
3266 lock_cache_file_with_progress(
3267 &lock_path,
3268 WasmBuildPhase::SharedTargetCoordination,
3269 progress,
3270 )?
3271 } else {
3272 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)?
3273 };
3274 Ok((lock, wait, canonical))
3275}
3276
3277fn lock_cache_file_with_progress(
3278 lock_path: &Path,
3279 phase: WasmBuildPhase,
3280 progress: &mut ProgressReporter<'_>,
3281) -> Result<(File, Duration), WasmBuildError> {
3282 let started = Instant::now();
3283 progress.begin_phase(phase);
3284 let result = if !progress.is_observed() || progress.config.heartbeat_interval.is_none() {
3285 lock_cache_file(lock_path).map_err(wasm_cache_fs_error)
3286 } else {
3287 let heartbeat_interval = progress
3288 .config
3289 .heartbeat_interval
3290 .expect("observed cache lock must have a heartbeat interval");
3291 lock_cache_file_with_wait_observer(lock_path, heartbeat_interval, |elapsed| {
3292 progress.emit_heartbeat_if_due(phase, elapsed);
3293 })
3294 .map_err(wasm_cache_fs_error)
3295 };
3296 progress.record_phase(phase, started.elapsed());
3297 result
3298}
3299
3300fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
3301 ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
3302}
3303
3304fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
3305 record_entry_use(path).map_err(wasm_cache_fs_error)
3306}
3307
3308fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
3309 WasmBuildError::Io {
3310 operation: error.operation,
3311 path: error.path,
3312 source: error.source,
3313 }
3314}
3315
3316fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3317 for (key, value) in &spec.environment {
3318 let key = key.as_encoded_bytes();
3319 if key.is_empty()
3320 || key.iter().any(|byte| matches!(byte, b'=' | 0))
3321 || value.as_encoded_bytes().contains(&0)
3322 {
3323 return Err(WasmBuildError::InvalidSpec {
3324 message: "invalid child environment name or value".to_owned(),
3325 });
3326 }
3327 }
3328 if spec.cargo_message_bytes == 0 {
3329 return Err(WasmBuildError::InvalidSpec {
3330 message: "Cargo message allowance must be nonzero".to_owned(),
3331 });
3332 }
3333 if spec.packages.is_empty() {
3334 return Err(WasmBuildError::InvalidSpec {
3335 message: "at least one Cargo package is required".to_owned(),
3336 });
3337 }
3338 if spec.target.is_empty() {
3339 return Err(WasmBuildError::InvalidSpec {
3340 message: "Cargo compilation target must not be empty".to_owned(),
3341 });
3342 }
3343 if spec
3344 .environment
3345 .contains_key(OsStr::new("CARGO_TARGET_DIR"))
3346 {
3347 return Err(WasmBuildError::InvalidSpec {
3348 message: "Cargo target directories are owned by the build specification; use target_dir or with_shared_incremental_target instead of command overrides".to_owned(),
3349 });
3350 }
3351 validate_cargo_target_selection(spec)?;
3352 spec.cargo_selection
3353 .profile_target_dir()
3354 .map_err(|message| WasmBuildError::InvalidSpec {
3355 message: message.to_owned(),
3356 })?;
3357 if matches!(
3358 &spec.cache_mode,
3359 WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
3360 ) {
3361 return Err(WasmBuildError::InvalidSpec {
3362 message: "shared incremental Cargo target directory must not be empty".to_owned(),
3363 });
3364 }
3365 if spec.shared_incremental_maintenance_config.is_some()
3366 && !matches!(
3367 spec.cache_mode,
3368 WasmBuildCacheMode::SharedIncremental { .. }
3369 )
3370 {
3371 return Err(WasmBuildError::InvalidSpec {
3372 message:
3373 "scheduled shared-target maintenance requires a shared incremental Cargo target"
3374 .to_owned(),
3375 });
3376 }
3377 Ok(())
3378}
3379
3380fn validate_cargo_target_selection(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3381 if let Some(example) = &spec.example {
3382 let mut components = Path::new(example).components();
3383 if spec.packages.len() != 1
3384 || !matches!(
3385 (components.next(), components.next()),
3386 (Some(Component::Normal(_)), None)
3387 )
3388 {
3389 return Err(WasmBuildError::InvalidSpec {
3390 message: "a named Cargo example requires one package, a normal target name"
3391 .to_owned(),
3392 });
3393 }
3394 }
3395 if spec.packages.iter().any(|package| {
3396 let mut components = Path::new(package).components();
3397 !matches!(
3398 (components.next(), components.next()),
3399 (Some(Component::Normal(_)), None)
3400 )
3401 }) {
3402 return Err(WasmBuildError::InvalidSpec {
3403 message: "Cargo package names must be normal path components".to_owned(),
3404 });
3405 }
3406 Ok(())
3407}
3408
3409fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3410 build_fingerprint_with_progress(spec, &mut ProgressReporter::silent())
3411}
3412
3413fn build_fingerprint_with_progress(
3414 spec: &WasmBuildSpec,
3415 progress: &mut ProgressReporter<'_>,
3416) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3417 build_fingerprint_with_policy(spec, progress, &mut None)
3418}
3419
3420fn build_fingerprint_with_policy(
3421 spec: &WasmBuildSpec,
3422 progress: &mut ProgressReporter<'_>,
3423 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3424) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3425 let total_started = Instant::now();
3426 let (cargo_identity, rustc_identity, tool_identity) =
3427 resolve_tool_identity_with_policy(spec, progress, policy)?;
3428
3429 let metadata_started = Instant::now();
3430 let metadata = progress.run_phase(WasmBuildPhase::CargoMetadata, || {
3431 cargo_metadata_with_policy(spec, policy)
3432 })?;
3433 let cargo_metadata = metadata_started.elapsed();
3434
3435 let discovery_started = Instant::now();
3436 let (inputs, exclusions) = progress.run_phase(WasmBuildPhase::InputDiscovery, || {
3437 let parsed =
3438 ParsedCargoMetadata::parse(&metadata).map_err(|message| invalid_metadata(&message))?;
3439 resolve_local_inputs(spec, &parsed)
3440 })?;
3441 let input_discovery = discovery_started.elapsed();
3442
3443 let hashing_started = Instant::now();
3444 let (input_digest, validation_digest) =
3445 progress.run_phase(WasmBuildPhase::ContentHashing, || {
3446 let mut cache = LabeledPathDigestCache::default();
3447 digest_resolved_local_inputs(
3448 &inputs,
3449 &exclusions,
3450 &mut cache,
3451 &spec.workspace_root,
3452 "hash Wasm build inputs",
3453 "hash semantic Wasm build inputs",
3454 )
3455 })?;
3456 let content_hashing = hashing_started.elapsed();
3457
3458 Ok(ResolvedCargoBuildInputs::assemble(
3459 spec,
3460 inputs,
3461 exclusions,
3462 input_digest,
3463 validation_digest,
3464 CargoBuildFacts::observed(spec, cargo_identity, rustc_identity),
3465 || WasmInputResolutionTimings {
3466 tool_identity,
3467 cargo_metadata,
3468 input_discovery,
3469 content_hashing,
3470 total: total_started.elapsed(),
3471 },
3472 ))
3473}
3474
3475fn cargo_build_inputs(inputs: Vec<(PathBuf, PathBuf)>) -> Arc<[CargoBuildInput]> {
3476 inputs
3477 .into_iter()
3478 .map(|(label, path)| CargoBuildInput { label, path })
3479 .collect()
3480}
3481
3482fn finish_build_fingerprint(
3483 spec: &WasmBuildSpec,
3484 facts: &CargoBuildFacts,
3485 input_digest: InputDigest,
3486) -> InputDigest {
3487 let mut hasher = InputHasher::new(BUILD_FINGERPRINT_DOMAIN);
3488 for package in &spec.packages {
3489 hasher.field("package", package.as_bytes());
3490 }
3491 hasher.field("target", facts.target.as_bytes());
3492 if let Some(example) = &spec.example {
3493 hasher.field("cargo-target-selection", b"example");
3494 hasher.field("cargo-example", example.as_bytes());
3495 } else {
3496 hasher.field("cargo-target-selection", b"lib");
3497 }
3498 hasher.field(
3499 "profile-target-dir",
3500 facts
3501 .selection
3502 .profile_target_dir()
3503 .expect("Cargo profile was validated before fingerprinting")
3504 .as_bytes(),
3505 );
3506 for argument in &facts.selection.build_arguments() {
3507 hasher.field("cargo-argument", &os_bytes(argument));
3508 }
3509 for (key, value) in &facts.environment {
3510 hasher.field("environment-key", &os_bytes(key));
3511 hasher.field("environment-value", &os_bytes(value));
3512 }
3513 hasher.field("cargo-identity", &facts.cargo_identity);
3514 hasher.field("rustc-identity", &facts.rustc_identity);
3515 hasher.field("source-input-digest", input_digest.as_bytes());
3516 hasher.finish()
3517}
3518
3519#[cfg(test)]
3520fn command_identity(
3521 spec: &WasmBuildSpec,
3522 phase: WasmBuildPhase,
3523 program: &OsStr,
3524 arguments: &[&str],
3525) -> Result<Vec<u8>, WasmBuildError> {
3526 command_identity_with_policy(spec, phase, program, arguments, &mut None)
3527}
3528
3529fn command_identity_with_policy(
3530 spec: &WasmBuildSpec,
3531 phase: WasmBuildPhase,
3532 program: &OsStr,
3533 arguments: &[&str],
3534 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3535) -> Result<Vec<u8>, WasmBuildError> {
3536 let mut command = Command::new(program);
3537 command.current_dir(&spec.workspace_root).args(arguments);
3538 apply_command_environment(&mut command, spec);
3539 let output =
3540 capture_probe_output_with_policy(&mut command, phase, TOOL_IDENTITY_BYTES, policy)?;
3541 ensure_command_success(phase, output).map(|output| {
3542 let mut identity = output.stdout;
3543 identity.extend_from_slice(&output.stderr);
3544 identity
3545 })
3546}
3547
3548fn cargo_metadata(spec: &WasmBuildSpec) -> Result<schema::Metadata, WasmBuildError> {
3549 cargo_metadata_with_policy(spec, &mut None)
3550}
3551
3552fn cargo_metadata_with_policy(
3553 spec: &WasmBuildSpec,
3554 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3555) -> Result<schema::Metadata, WasmBuildError> {
3556 let mut command = Command::new(&spec.cargo_program);
3557 command
3558 .current_dir(&spec.workspace_root)
3559 .args(["metadata", "--format-version", "1"]);
3560 for argument in spec.cargo_selection.metadata_arguments() {
3561 command.arg(argument);
3562 }
3563 apply_command_environment(&mut command, spec);
3564 let output = capture_probe_output_with_policy(
3565 &mut command,
3566 WasmBuildPhase::CargoMetadata,
3567 CARGO_METADATA_BYTES,
3568 policy,
3569 )?;
3570 let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
3571 crate::cargo::observation::decode_metadata(&output.stdout).map_err(|error| {
3572 WasmBuildError::InvalidMetadata {
3573 message: format!("Cargo metadata did not match its schema: {error}"),
3574 }
3575 })
3576}
3577
3578#[cfg(test)]
3579fn capture_probe_output(
3580 command: &mut Command,
3581 phase: WasmBuildPhase,
3582 stdout_bytes: usize,
3583) -> Result<CompletedExecution, WasmBuildError> {
3584 capture_probe_output_with_policy(command, phase, stdout_bytes, &mut None)
3585}
3586
3587fn capture_probe_output_with_policy(
3588 command: &mut Command,
3589 phase: WasmBuildPhase,
3590 stdout_bytes: usize,
3591 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3592) -> Result<CompletedExecution, WasmBuildError> {
3593 let limits = OutputLimits {
3594 stdout: OutputLimit::Terminate(stdout_bytes),
3595 stderr: OutputLimit::Terminate(TOOL_IDENTITY_BYTES),
3596 timeout: None,
3597 };
3598 let result = if let Some(policy) = policy {
3599 capture_controlled_probe(command, limits, policy)
3600 } else {
3601 capture_command(command, limits)
3602 }
3603 .and_then(CompletedExecution::require_complete);
3604 let evidence = match result {
3605 Ok(evidence) => evidence,
3606 Err(ToolError::Execution(error))
3607 if matches!(
3608 error.failure,
3609 ExecutionFailure::Io {
3610 operation: ExecutionOperation::Spawn,
3611 ..
3612 }
3613 ) && error.cleanup.is_none() =>
3614 {
3615 let ExecutionFailure::Io { source, .. } = error.failure else {
3616 unreachable!()
3617 };
3618 return Err(WasmBuildError::CommandSpawn {
3619 phase,
3620 program: command.get_program().to_owned(),
3621 source,
3622 });
3623 }
3624 Err(error) => {
3625 return Err(WasmBuildError::Io {
3626 operation: match phase {
3627 WasmBuildPhase::CargoIdentity => "capture complete Cargo identity probe output",
3628 WasmBuildPhase::RustcIdentity => "capture complete rustc identity probe output",
3629 _ => "capture complete Cargo metadata probe output",
3630 },
3631 path: PathBuf::from(command.get_program()),
3632 source: io::Error::other(error),
3633 });
3634 }
3635 };
3636 Ok(evidence)
3637}
3638
3639fn capture_controlled_probe(
3640 command: &mut Command,
3641 mut limits: OutputLimits,
3642 policy: &mut CargoInputProbePolicy<'_>,
3643) -> Result<CompletedExecution, ToolError> {
3644 let failure = if (policy.cancelled)() {
3645 Some(ExecutionFailure::Cancelled)
3646 } else if policy
3647 .deadline
3648 .is_some_and(|deadline| deadline <= Instant::now())
3649 {
3650 Some(ExecutionFailure::TimedOut)
3651 } else {
3652 None
3653 };
3654 if let Some(failure) = failure {
3655 return Err(ToolError::Execution(Box::new(ExecutionError {
3656 failure,
3657 evidence: ExecutionEvidence::default(),
3658 cleanup: None,
3659 })));
3660 }
3661 command
3662 .stdin(Stdio::null())
3663 .stdout(Stdio::piped())
3664 .stderr(Stdio::piped());
3665 let mut child = OwnedChild::spawn(command, policy.cleanup).map_err(|source| {
3666 ToolError::Execution(Box::new(ExecutionError {
3667 failure: ExecutionFailure::Io {
3668 operation: ExecutionOperation::Spawn,
3669 source,
3670 },
3671 evidence: ExecutionEvidence::default(),
3672 cleanup: None,
3673 }))
3674 })?;
3675 limits.timeout = policy.deadline.map(|deadline| {
3677 deadline
3678 .saturating_duration_since(Instant::now())
3679 .max(Duration::from_nanos(1))
3680 });
3681 communicate_child(
3682 &mut child,
3683 None,
3684 limits,
3685 SuccessfulExit::Cleanup,
3686 &mut *policy.cancelled,
3687 )
3688}
3689
3690struct ParsedCargoMetadata<'a> {
3693 packages: HashMap<&'a str, &'a schema::Package>,
3694 workspace_packages: HashMap<&'a str, Option<&'a str>>,
3695 nodes: HashMap<&'a str, &'a schema::Node>,
3696 workspace_root: &'a Path,
3697}
3698
3699impl<'a> ParsedCargoMetadata<'a> {
3700 fn parse(metadata: &'a schema::Metadata) -> Result<Self, String> {
3701 let packages: HashMap<_, _> = metadata
3702 .packages
3703 .iter()
3704 .map(|package| (package.id.repr.as_str(), package))
3705 .collect();
3706 let workspace_members: HashSet<_> = metadata
3707 .workspace_members
3708 .iter()
3709 .map(|id| id.repr.as_str())
3710 .collect();
3711 let mut workspace_packages = HashMap::new();
3712 for package in packages
3713 .values()
3714 .filter(|package| workspace_members.contains(package.id.repr.as_str()))
3715 {
3716 workspace_packages
3717 .entry(package.name.as_str())
3718 .and_modify(|id| *id = None)
3719 .or_insert(Some(package.id.repr.as_str()));
3720 }
3721 let resolve = metadata
3722 .resolve
3723 .as_ref()
3724 .ok_or_else(|| "Cargo metadata has no resolved dependency nodes".to_owned())?;
3725 let nodes = resolve
3726 .nodes
3727 .iter()
3728 .map(|node| (node.id.repr.as_str(), node))
3729 .collect();
3730 Ok(Self {
3731 packages,
3732 workspace_packages,
3733 nodes,
3734 workspace_root: metadata.workspace_root.as_std_path(),
3735 })
3736 }
3737}
3738
3739struct LockedPackageIdentity {
3740 name: String,
3741 version: String,
3742 source: String,
3743 checksum: Option<String>,
3744}
3745
3746type LockedPackageIndex<'a> = HashMap<(&'a str, &'a str, &'a str), &'a LockedPackageIdentity>;
3747
3748fn index_locked_packages(packages: &[LockedPackageIdentity]) -> LockedPackageIndex<'_> {
3749 let mut index = HashMap::with_capacity(packages.len());
3750 for package in packages {
3751 index
3753 .entry((
3754 package.name.as_str(),
3755 package.version.as_str(),
3756 package.source.as_str(),
3757 ))
3758 .or_insert(package);
3759 }
3760 index
3761}
3762
3763fn resolve_local_inputs(
3764 spec: &WasmBuildSpec,
3765 metadata: &ParsedCargoMetadata<'_>,
3766) -> Result<(ResolvedLocalInputs, Vec<PathBuf>), WasmBuildError> {
3767 let mut selected_ids = selected_package_ids(spec, metadata)?;
3768 let mut artifact_names = BTreeSet::new();
3771 let wasm_targets = selected_ids
3772 .iter()
3773 .map(|id| {
3774 let package = metadata.packages[id];
3775 let mut matching = package.targets.iter().filter(|target| {
3776 target.crate_types.contains(&schema::CrateType::CDyLib)
3777 && spec.example.as_ref().map_or_else(
3778 || target.is_cdylib(),
3779 |example| target.name == *example && target.is_example(),
3780 )
3781 });
3782 let Some(target) = matching.next() else {
3783 return Err(format!(
3784 "Cargo package `{}` must declare the selected cdylib target",
3785 package.name
3786 ));
3787 };
3788 let mut components = Path::new(&target.name).components();
3789 if matching.next().is_some()
3790 || !matches!(
3791 (components.next(), components.next()),
3792 (Some(Component::Normal(_)), None)
3793 )
3794 {
3795 return Err(format!(
3796 "Cargo package `{}` has an ambiguous or unsafe cdylib target",
3797 package.name
3798 ));
3799 }
3800 let cache_name = target.name.replace('-', "_");
3801 if !artifact_names.insert(cache_name.clone()) {
3802 return Err(format!(
3803 "selected Cargo packages share the Wasm artifact name `{cache_name}`"
3804 ));
3805 }
3806 Ok(WasmArtifactTarget {
3807 package_id: package.id.repr.clone(),
3808 name: target.name.clone(),
3809 kind: if spec.example.is_some() {
3810 schema::TargetKind::Example
3811 } else {
3812 schema::TargetKind::CDyLib
3813 },
3814 })
3815 })
3816 .collect();
3817 let mut closure = BTreeSet::new();
3818 while let Some(id) = selected_ids.pop_front() {
3819 if !closure.insert(id) {
3820 continue;
3821 }
3822 if let Some(node) = metadata.nodes.get(id) {
3823 selected_ids.extend(
3824 node.deps
3825 .iter()
3826 .map(|dependency| dependency.pkg.repr.as_str()),
3827 );
3828 }
3829 }
3830
3831 let workspace_root = metadata.workspace_root;
3832 let workspace_projection = semantic_workspace_projection(metadata, &closure, workspace_root)?;
3833 let mut validation_inputs = workspace_configuration_inputs(spec, workspace_root)?;
3834 append_package_inputs(
3835 &mut validation_inputs,
3836 &metadata.packages,
3837 closure,
3838 workspace_root,
3839 )?;
3840 append_additional_inputs(&mut validation_inputs, spec, workspace_root);
3841 validate_shared_incremental_target_boundary(spec, &validation_inputs)?;
3842 let exclusions = source_exclusions(spec, &validation_inputs);
3843 Ok((
3844 ResolvedLocalInputs {
3845 validation_inputs,
3846 workspace_projection,
3847 wasm_targets,
3848 },
3849 exclusions,
3850 ))
3851}
3852
3853fn selected_package_ids<'a>(
3854 spec: &WasmBuildSpec,
3855 metadata: &ParsedCargoMetadata<'a>,
3856) -> Result<VecDeque<&'a str>, WasmBuildError> {
3857 let mut selected_ids = VecDeque::new();
3858 for requested in &spec.packages {
3859 match metadata.workspace_packages.get(requested.as_str()) {
3860 Some(Some(id)) => selected_ids.push_back(*id),
3861 None => {
3862 return Err(WasmBuildError::InvalidSpec {
3863 message: format!("Cargo workspace contains no package named `{requested}`"),
3864 });
3865 }
3866 Some(None) => {
3867 return Err(WasmBuildError::InvalidSpec {
3868 message: format!("Cargo workspace package name `{requested}` is ambiguous"),
3869 });
3870 }
3871 }
3872 }
3873 Ok(selected_ids)
3874}
3875
3876fn semantic_workspace_projection(
3877 metadata: &ParsedCargoMetadata<'_>,
3878 closure: &BTreeSet<&str>,
3879 workspace_root: &Path,
3880) -> Result<Option<InputDigest>, WasmBuildError> {
3881 let locked_packages = locked_package_identities(workspace_root)?;
3884 let locked_index = index_locked_packages(&locked_packages);
3885 let mut identities = HashMap::new();
3886 for &id in closure {
3887 let package = metadata
3888 .packages
3889 .get(id)
3890 .ok_or_else(|| invalid_metadata(&format!("resolved package `{id}` is missing")))?;
3891 let Some(identity) = semantic_package_identity(package, workspace_root, &locked_index)
3892 else {
3893 return Ok(None);
3894 };
3895 identities.insert(id, identity);
3896 }
3897
3898 let mut projected_packages = closure
3899 .iter()
3900 .map(|&id| {
3901 let package = metadata
3902 .packages
3903 .get(id)
3904 .expect("selected package closure was validated above");
3905 let identity = identities[id];
3906 let node = metadata.nodes.get(id).ok_or_else(|| {
3907 invalid_metadata(&format!("resolved package `{id}` has no dependency node"))
3908 })?;
3909 let projection = semantic_package_projection(package, node, &identities)?;
3910 Ok::<_, WasmBuildError>((identity, projection))
3911 })
3912 .collect::<Result<Vec<_>, _>>()?;
3913 projected_packages.sort_by_key(|(identity, _)| *identity);
3914
3915 let root_manifest = workspace_root.join("Cargo.toml");
3916 let root_contents =
3917 fs::read_to_string(&root_manifest).map_err(|source| WasmBuildError::Io {
3918 operation: "read workspace manifest for semantic projection",
3919 path: root_manifest.clone(),
3920 source,
3921 })?;
3922 let root = toml::from_str::<TomlValue>(&root_contents).map_err(|error| {
3923 invalid_metadata(&format!(
3924 "workspace manifest could not be projected as TOML: {error}"
3925 ))
3926 })?;
3927
3928 let mut hasher = InputHasher::new("wasm-semantic-workspace-projection-v1");
3929 for (identity, projection) in projected_packages {
3930 hasher.field("package-identity", identity.as_bytes());
3931 hasher.field("package-projection", projection.as_bytes());
3932 }
3933 hash_toml_setting(&mut hasher, "cargo-features", root.get("cargo-features"));
3934 hash_toml_setting(&mut hasher, "profile", root.get("profile"));
3935 let workspace = root.get("workspace").and_then(TomlValue::as_table);
3936 hash_toml_setting(
3937 &mut hasher,
3938 "workspace-resolver",
3939 workspace.and_then(|table| table.get("resolver")),
3940 );
3941 hash_toml_setting(
3942 &mut hasher,
3943 "workspace-lints",
3944 workspace.and_then(|table| table.get("lints")),
3945 );
3946 Ok(Some(hasher.finish()))
3947}
3948
3949fn locked_package_identities(
3950 workspace_root: &Path,
3951) -> Result<Vec<LockedPackageIdentity>, WasmBuildError> {
3952 use crate::cargo::lockfile::{external_package, optional_string, required_string};
3953 let lockfile = workspace_root.join("Cargo.lock");
3954 let contents = match fs::read_to_string(&lockfile) {
3955 Ok(contents) => contents,
3956 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
3957 Err(source) => {
3958 return Err(WasmBuildError::Io {
3959 operation: "read Cargo lockfile for semantic projection",
3960 path: lockfile,
3961 source,
3962 });
3963 }
3964 };
3965 let lock = toml::from_str::<TomlValue>(&contents).map_err(|error| {
3966 invalid_metadata(&format!(
3967 "Cargo lockfile could not be projected as TOML: {error}"
3968 ))
3969 })?;
3970 let Some(packages) = lock.get("package").and_then(TomlValue::as_array) else {
3971 return Ok(Vec::new());
3972 };
3973 packages
3974 .iter()
3975 .filter_map(|package| {
3976 let table = match external_package(package) {
3977 Ok(Some(table)) => table,
3978 Ok(None) => return None,
3979 Err(message) => {
3980 return Some(Err(invalid_metadata(&format!("Cargo lockfile {message}"))));
3981 }
3982 };
3983 let text = |field| {
3984 required_string(table, field)
3985 .map(str::to_owned)
3986 .map_err(|_| {
3987 invalid_metadata(&format!(
3988 "Cargo lockfile package `{field}` is missing or not a string"
3989 ))
3990 })
3991 };
3992 let source = required_string(table, "source").map(str::to_owned);
3993 Some(
3994 source
3995 .map_err(|_| invalid_metadata("Cargo lockfile package source is not a string"))
3996 .and_then(|source| {
3997 Ok(LockedPackageIdentity {
3998 name: text("name")?,
3999 version: text("version")?,
4000 source,
4001 checksum: optional_string(table, "checksum")
4002 .map_err(|_| {
4003 invalid_metadata(
4004 "Cargo lockfile package `checksum` is not a string",
4005 )
4006 })?
4007 .map(str::to_owned),
4008 })
4009 }),
4010 )
4011 })
4012 .collect()
4013}
4014
4015fn semantic_package_identity(
4016 package: &schema::Package,
4017 workspace_root: &Path,
4018 locked_packages: &LockedPackageIndex<'_>,
4019) -> Option<InputDigest> {
4020 let version = package.version.to_string();
4021 let mut hasher = InputHasher::new("wasm-semantic-package-identity-v1");
4022 hasher.field("name", package.name.as_bytes());
4023 hasher.field("version", version.as_bytes());
4024 if package.source.is_none() {
4025 let manifest = package.manifest_path.strip_prefix(workspace_root).ok()?;
4026 let package_root = package.manifest_path.parent()?;
4027 if package_root == workspace_root {
4028 return None;
4029 }
4030 hasher.field("local-manifest", &os_bytes(manifest.as_os_str()));
4031 } else {
4032 let metadata_source = &package.source.as_ref()?.repr;
4033 let locked = locked_packages.get(&(
4034 package.name.as_str(),
4035 version.as_str(),
4036 metadata_source.as_str(),
4037 ))?;
4038 match locked.source.as_str() {
4039 source if source.starts_with("registry+") && locked.checksum.is_some() => {}
4040 source if source.starts_with("git+") && source.contains('#') => {}
4041 _ => return None,
4042 }
4043 hasher.field("external-package-id", package.id.repr.as_bytes());
4044 hasher.field("external-source", locked.source.as_bytes());
4045 hasher.field(
4046 "external-checksum",
4047 locked.checksum.as_deref().unwrap_or_default().as_bytes(),
4048 );
4049 }
4050 Some(hasher.finish())
4051}
4052
4053fn semantic_package_projection(
4054 package: &schema::Package,
4055 node: &schema::Node,
4056 identities: &HashMap<&str, InputDigest>,
4057) -> Result<InputDigest, WasmBuildError> {
4058 let mut hasher = InputHasher::new("wasm-semantic-package-projection-v2");
4062 macro_rules! fields {
4065 ($($field:ident),* $(,)?) => {$(
4066 hasher.field("package-field-name", stringify!($field).as_bytes());
4067 hasher.field("package-field-value", &serde_json::to_vec(&package.$field)
4068 .expect("serializing Cargo fields into a Vec cannot fail"));
4069 )*};
4070 }
4071 fields!(
4072 authors,
4073 default_run,
4074 description,
4075 documentation,
4076 edition,
4077 homepage,
4078 license,
4079 license_file,
4080 links,
4081 metadata,
4082 name,
4083 readme,
4084 repository,
4085 rust_version,
4086 version
4087 );
4088
4089 let mut features = node
4090 .features
4091 .iter()
4092 .map(|feature| feature.as_str())
4093 .collect::<Vec<_>>();
4094 features.sort_unstable();
4095 for feature in features {
4096 hasher.field("enabled-feature", feature.as_bytes());
4097 }
4098
4099 let mut dependencies = node
4100 .deps
4101 .iter()
4102 .map(|dependency| {
4103 if dependency.dep_kinds.iter().any(|kind| {
4104 !matches!(
4105 kind.kind,
4106 schema::DependencyKind::Normal
4107 | schema::DependencyKind::Development
4108 | schema::DependencyKind::Build
4109 )
4110 }) {
4111 return Err(invalid_metadata("unsupported Cargo dependency kind"));
4112 }
4113 let name = dependency.name.as_str();
4114 let package_id = dependency.pkg.repr.as_str();
4115 let identity = identities.get(package_id).copied().ok_or_else(|| {
4116 invalid_metadata(&format!(
4117 "dependency `{package_id}` is outside the selected package closure"
4118 ))
4119 })?;
4120 let kinds = serde_json::to_vec(&dependency.dep_kinds)
4121 .expect("serializing Cargo dependency kinds into a Vec cannot fail");
4122 Ok::<_, WasmBuildError>((name, identity, kinds))
4123 })
4124 .collect::<Result<Vec<_>, _>>()?;
4125 dependencies.sort();
4126 for (name, identity, kinds) in dependencies {
4127 hasher.field("dependency-name", name.as_bytes());
4128 hasher.field("dependency-identity", identity.as_bytes());
4129 hasher.field("dependency-kinds", &kinds);
4130 }
4131 Ok(hasher.finish())
4132}
4133
4134fn hash_toml_setting(hasher: &mut InputHasher, label: &str, value: Option<&TomlValue>) {
4135 hasher.field("workspace-setting-name", label.as_bytes());
4136 match value {
4137 Some(value) => hasher.field("workspace-setting-value", value.to_string().as_bytes()),
4138 None => hasher.field("workspace-setting-missing", b""),
4139 }
4140}
4141
4142fn is_broad_workspace_input(label: &Path) -> bool {
4143 label == Path::new("workspace/Cargo.toml") || label == Path::new("workspace/Cargo.lock")
4144}
4145
4146fn digest_resolved_local_inputs(
4147 inputs: &ResolvedLocalInputs,
4148 exclusions: &[PathBuf],
4149 cache: &mut LabeledPathDigestCache,
4150 error_path: &Path,
4151 validation_operation: &'static str,
4152 semantic_operation: &'static str,
4153) -> Result<(InputDigest, InputDigest), WasmBuildError> {
4154 let validation_digest = digest_labeled_paths_composable(
4155 "wasm-source-inputs-v1",
4156 inputs
4157 .validation_inputs
4158 .iter()
4159 .map(|(label, path)| (label.as_path(), path.as_path())),
4160 exclusions,
4161 cache,
4162 )
4163 .map_err(|source| WasmBuildError::Io {
4164 operation: validation_operation,
4165 path: error_path.to_owned(),
4166 source,
4167 })?;
4168 let input_digest = semantic_input_digest(inputs, validation_digest, exclusions, cache)
4169 .map_err(|source| WasmBuildError::Io {
4170 operation: semantic_operation,
4171 path: error_path.to_owned(),
4172 source,
4173 })?;
4174 Ok((input_digest, validation_digest))
4175}
4176
4177fn semantic_input_digest(
4178 inputs: &ResolvedLocalInputs,
4179 validation_digest: InputDigest,
4180 exclusions: &[PathBuf],
4181 cache: &mut LabeledPathDigestCache,
4182) -> io::Result<InputDigest> {
4183 let Some(workspace) = inputs.workspace_projection else {
4184 return Ok(validation_digest);
4185 };
4186 let path_digest = digest_labeled_paths_composable(
4187 "wasm-source-inputs-v1",
4188 inputs
4189 .validation_inputs
4190 .iter()
4191 .filter(|(label, _)| !is_broad_workspace_input(label))
4192 .map(|(label, path)| (label.as_path(), path.as_path())),
4193 exclusions,
4194 cache,
4195 )?;
4196 let mut hasher = InputHasher::new("wasm-semantic-source-inputs-v1");
4197 hasher.field("path-input-digest", path_digest.as_bytes());
4198 hasher.field("workspace-projection", workspace.as_bytes());
4199 Ok(hasher.finish())
4200}
4201
4202fn workspace_configuration_inputs(
4203 spec: &WasmBuildSpec,
4204 workspace_root: &Path,
4205) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
4206 let mut inputs = Vec::new();
4207 add_if_present(
4208 &mut inputs,
4209 "workspace/Cargo.toml",
4210 workspace_root.join("Cargo.toml"),
4211 );
4212 add_if_present(
4213 &mut inputs,
4214 "workspace/Cargo.lock",
4215 workspace_root.join("Cargo.lock"),
4216 );
4217 add_if_present(
4218 &mut inputs,
4219 "workspace/rust-toolchain.toml",
4220 workspace_root.join("rust-toolchain.toml"),
4221 );
4222 add_if_present(
4223 &mut inputs,
4224 "workspace/rust-toolchain",
4225 workspace_root.join("rust-toolchain"),
4226 );
4227 append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
4228 Ok(inputs)
4229}
4230
4231fn append_cargo_configuration_inputs(
4232 inputs: &mut Vec<(PathBuf, PathBuf)>,
4233 spec: &WasmBuildSpec,
4234 workspace_root: &Path,
4235) -> Result<(), WasmBuildError> {
4236 let invocation_root =
4237 spec.workspace_root
4238 .canonicalize()
4239 .map_err(|source| WasmBuildError::Io {
4240 operation: "resolve Cargo invocation directory",
4241 path: spec.workspace_root.clone(),
4242 source,
4243 })?;
4244 let canonical_workspace =
4245 workspace_root
4246 .canonicalize()
4247 .map_err(|source| WasmBuildError::Io {
4248 operation: "resolve Cargo workspace directory",
4249 path: workspace_root.to_owned(),
4250 source,
4251 })?;
4252
4253 let cargo_home = command_environment_value(spec, "CARGO_HOME");
4254 let default_home = default_home_directory(spec);
4255 let directories = crate::cargo::environment::configuration_directories(
4256 &invocation_root,
4257 cargo_home.as_deref(),
4258 default_home.as_deref().map(Path::as_os_str),
4259 )
4260 .map_err(|source| WasmBuildError::Io {
4261 operation: "resolve Cargo configuration directories",
4262 path: invocation_root.clone(),
4263 source,
4264 })?;
4265 let roots: Vec<_> = directories
4266 .iter()
4267 .filter_map(|directory| crate::cargo::environment::preferred_configuration(directory))
4268 .collect();
4269 let observed =
4270 crate::cargo::environment::observe_configurations(&roots).map_err(|error| match error {
4271 crate::cargo::environment::ConfigurationError::Io {
4272 operation,
4273 path,
4274 source,
4275 } => WasmBuildError::Io {
4276 operation,
4277 path,
4278 source,
4279 },
4280 crate::cargo::environment::ConfigurationError::Invalid { path, message } => {
4281 WasmBuildError::InvalidCargoConfiguration { path, message }
4282 }
4283 })?;
4284 for config in observed {
4285 if !inputs.iter().any(|(_, path)| path == &config.path) {
4286 inputs.push((
4287 cargo_configuration_label(&config.location, &canonical_workspace),
4288 config.path,
4289 ));
4290 }
4291 }
4292 Ok(())
4293}
4294
4295#[cfg(windows)]
4296fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4297 command_environment_value(spec, "USERPROFILE")
4298 .or_else(|| command_environment_value(spec, "HOME"))
4299 .map(PathBuf::from)
4300}
4301
4302#[cfg(not(windows))]
4303fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4304 command_environment_value(spec, "HOME").map(PathBuf::from)
4305}
4306
4307fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
4308 spec.environment.get(OsStr::new(name)).cloned()
4309}
4310
4311fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
4312 if let Ok(relative) = config.strip_prefix(workspace_root) {
4313 return PathBuf::from("cargo-config/workspace").join(relative);
4314 }
4315 let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
4316 PathBuf::from("cargo-config/external").join(location.to_hex())
4317}
4318
4319fn append_package_inputs(
4320 inputs: &mut Vec<(PathBuf, PathBuf)>,
4321 packages: &HashMap<&str, &schema::Package>,
4322 closure: BTreeSet<&str>,
4323 workspace_root: &Path,
4324) -> Result<(), WasmBuildError> {
4325 for id in closure {
4326 let Some(package) = packages.get(id) else {
4327 return Err(invalid_metadata(&format!(
4328 "resolved package `{id}` is missing"
4329 )));
4330 };
4331 if package.source.is_some() {
4332 continue;
4333 }
4334 let root = package.manifest_path.parent().ok_or_else(|| {
4335 invalid_metadata(&format!(
4336 "package `{}` manifest has no parent",
4337 package.name
4338 ))
4339 })?;
4340 let relative_manifest = package
4341 .manifest_path
4342 .as_std_path()
4343 .strip_prefix(workspace_root)
4344 .unwrap_or(package.manifest_path.as_std_path());
4345 let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
4346 .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
4347 inputs.push((label, root.as_std_path().to_owned()));
4348 }
4349 Ok(())
4350}
4351
4352fn append_additional_inputs(
4353 inputs: &mut Vec<(PathBuf, PathBuf)>,
4354 spec: &WasmBuildSpec,
4355 workspace_root: &Path,
4356) {
4357 for additional in &spec.additional_inputs {
4358 let path = if additional.is_absolute() {
4359 additional.clone()
4360 } else {
4361 workspace_root.join(additional)
4362 };
4363 inputs.push((PathBuf::from("additional").join(additional), path));
4364 }
4365}
4366
4367fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
4368 let mut exclusions = vec![
4369 spec.target_dir.clone(),
4370 spec.workspace_root.join("target"),
4371 spec.workspace_root.join(".git"),
4372 ];
4373 if let Some(shared_target) = shared_incremental_target(spec) {
4374 exclusions.push(shared_target);
4375 }
4376 for (_, path) in inputs {
4377 if path.is_dir() {
4378 exclusions.push(path.join("target"));
4379 exclusions.push(path.join(".git"));
4380 }
4381 }
4382 exclusions
4383}
4384
4385fn validate_shared_incremental_target_boundary(
4386 spec: &WasmBuildSpec,
4387 inputs: &[(PathBuf, PathBuf)],
4388) -> Result<(), WasmBuildError> {
4389 let Some(shared_target) = shared_incremental_target(spec) else {
4390 return Ok(());
4391 };
4392 let shared_target =
4393 canonicalize_allow_missing(&shared_target).map_err(|source| WasmBuildError::Io {
4394 operation: "resolve shared incremental Cargo target boundary",
4395 path: shared_target.clone(),
4396 source,
4397 })?;
4398 let exact_entries = spec.target_dir.join(".ic-testkit/wasm-targets");
4399 let exact_entries =
4400 canonicalize_allow_missing(&exact_entries).map_err(|source| WasmBuildError::Io {
4401 operation: "resolve exact Wasm cache boundary",
4402 path: exact_entries,
4403 source,
4404 })?;
4405 if shared_target.starts_with(&exact_entries)
4408 || (exact_entries.starts_with(&shared_target)
4409 && !exact_entries.starts_with(shared_target.join(".ic-testkit")))
4410 {
4411 return Err(WasmBuildError::InvalidSpec {
4412 message: "shared incremental target must not overlap removable exact Wasm cache state"
4413 .to_owned(),
4414 });
4415 }
4416 let mut boundaries = super::inputs::boundaries::CargoInputBoundaries::default();
4417 let resolved_inputs = inputs
4418 .iter()
4419 .map(|(_, input)| {
4420 boundaries
4421 .input(
4422 input,
4423 "resolve Cargo input boundary",
4424 "inspect Cargo input boundary",
4425 )
4426 .cloned()
4427 .map_err(wasm_cache_fs_error)
4428 })
4429 .collect::<Result<Vec<_>, WasmBuildError>>()?;
4430 let has_safe_generated_root = std::iter::once(spec.target_dir.clone())
4431 .chain(std::iter::once(spec.workspace_root.join("target")))
4432 .chain(
4433 inputs
4434 .iter()
4435 .zip(&resolved_inputs)
4436 .filter(|(_, (_, is_directory))| *is_directory)
4437 .map(|((_, path), _)| path.join("target")),
4438 )
4439 .filter_map(|path| canonicalize_allow_missing(&path).ok())
4440 .filter(|root| {
4441 !resolved_inputs
4442 .iter()
4443 .any(|(input, _is_directory)| input.starts_with(root))
4444 })
4445 .fold(false, |safe, root| safe | shared_target.starts_with(root));
4447 if has_safe_generated_root {
4448 return Ok(());
4449 }
4450
4451 for (input, is_directory) in resolved_inputs {
4452 if shared_target == input
4453 || (is_directory && shared_target.starts_with(&input))
4454 || input.starts_with(&shared_target)
4455 {
4456 return Err(WasmBuildError::InvalidSpec {
4457 message: format!(
4458 "shared incremental target {} must not overlap exact Cargo inputs unless it is inside a generated target directory",
4459 shared_target.display()
4460 ),
4461 });
4462 }
4463 }
4464 Ok(())
4465}
4466
4467pub(super) fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
4468 let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
4469 return None;
4470 };
4471 Some(if target_dir.is_absolute() {
4472 target_dir.clone()
4473 } else {
4474 spec.workspace_root.join(target_dir)
4475 })
4476}
4477
4478fn shared_incremental_target_exists(
4479 spec: &WasmBuildSpec,
4480 operation: &'static str,
4481) -> Result<bool, WasmBuildError> {
4482 let target_dir =
4483 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
4484 message: "shared incremental target is not configured".to_owned(),
4485 })?;
4486 match fs::symlink_metadata(&target_dir) {
4487 Ok(metadata) if metadata.is_dir() => Ok(true),
4488 Ok(_) => Err(WasmBuildError::InvalidSpec {
4489 message: format!(
4490 "shared incremental Cargo target {} must be a directory",
4491 target_dir.display()
4492 ),
4493 }),
4494 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
4495 Err(source) => Err(WasmBuildError::Io {
4496 operation,
4497 path: target_dir,
4498 source,
4499 }),
4500 }
4501}
4502
4503fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
4504 command.env_clear().envs(&spec.environment);
4505}
4506
4507fn run_cargo_build(
4508 spec: &WasmBuildSpec,
4509 build_target_dir: &Path,
4510 targets: &[WasmArtifactTarget],
4511 progress: &mut ProgressReporter<'_>,
4512 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
4513) -> Result<Vec<PathBuf>, WasmBuildError> {
4514 let command = cargo_build_command(spec, build_target_dir)?;
4515 communicate_cargo_build(
4516 spec,
4517 build_target_dir,
4518 targets,
4519 command,
4520 progress,
4521 &mut spawn,
4522 )
4523}
4524
4525fn cargo_build_command(
4526 spec: &WasmBuildSpec,
4527 build_target_dir: &Path,
4528) -> Result<Command, WasmBuildError> {
4529 let absolute_target_dir =
4530 canonicalize_allow_missing(build_target_dir).map_err(|source| WasmBuildError::Io {
4531 operation: "resolve Cargo build target directory",
4532 path: build_target_dir.to_owned(),
4533 source,
4534 })?;
4535 let mut command = Command::new(&spec.cargo_program);
4536 apply_command_environment(&mut command, spec);
4537 command
4538 .current_dir(&spec.workspace_root)
4539 .env("CARGO_TARGET_DIR", absolute_target_dir)
4540 .args(["build", "--message-format=json"]);
4541 if let Some(example) = &spec.example {
4542 command.args(["--example", example]);
4543 } else {
4544 command.arg("--lib");
4545 }
4546 command
4547 .args(["--target", &spec.target])
4548 .args(spec.cargo_selection.build_arguments());
4549 for package in &spec.packages {
4550 command.args(["-p", package]);
4551 }
4552
4553 Ok(command)
4554}
4555
4556fn communicate_cargo_build(
4557 spec: &WasmBuildSpec,
4558 build_target_dir: &Path,
4559 targets: &[WasmArtifactTarget],
4560 mut command: Command,
4561 progress: &mut ProgressReporter<'_>,
4562 spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
4563) -> Result<Vec<PathBuf>, WasmBuildError> {
4564 command
4567 .stdin(Stdio::null())
4568 .stdout(Stdio::piped())
4569 .stderr(Stdio::piped());
4570 let started = Instant::now();
4571 let mut child = spawn(&mut command).map_err(|source| WasmBuildError::CommandSpawn {
4572 phase: WasmBuildPhase::CargoBuild,
4573 program: spec.cargo_program.clone(),
4574 source,
4575 })?;
4576 progress.emit(WasmBuildProgressEvent::CargoStarted {
4577 target_dir: build_target_dir.to_owned(),
4578 });
4579 let progress = RefCell::new(progress);
4580 let mut artifacts = vec![None; targets.len()];
4581 let mut collect = |message| collect_wasm_artifact(message, targets, &mut artifacts);
4582 let observed = communicate_cargo(
4583 &mut child,
4584 OutputLimits {
4585 stdout: OutputLimit::Truncate(CARGO_DIAGNOSTIC_BYTES),
4588 stderr: OutputLimit::Truncate(CARGO_DIAGNOSTIC_BYTES),
4589 timeout: None,
4590 },
4591 spec.cargo_message_bytes,
4592 || {
4593 progress
4594 .borrow_mut()
4595 .emit_heartbeat_if_due(WasmBuildPhase::CargoBuild, started.elapsed());
4596 false
4597 },
4598 |stream, bytes| {
4599 let mut progress = progress.borrow_mut();
4600 if progress.config.emit_cargo_output {
4601 progress.emit(WasmBuildProgressEvent::CargoOutput {
4602 stream,
4603 bytes: bytes.to_vec(),
4604 });
4605 }
4606 Ok(())
4607 },
4608 &mut collect,
4609 );
4610 let result = observed.execution;
4611 let status = match &result {
4612 Ok(evidence) => Some(evidence.status),
4613 Err(error) => error.evidence().and_then(|evidence| evidence.status),
4614 };
4615 if let Some(status) = status {
4616 progress
4617 .borrow_mut()
4618 .emit(WasmBuildProgressEvent::CargoFinished {
4619 success: status.success(),
4620 code: status.code(),
4621 elapsed: started.elapsed(),
4622 });
4623 }
4624 let evidence = result.map_err(|error| WasmBuildError::Io {
4625 operation: "communicate with cargo build",
4626 path: PathBuf::from(&spec.cargo_program),
4627 source: io::Error::other(error),
4629 })?;
4630 let status = evidence.status;
4631 if status.success() {
4632 observed.messages.map_err(|source| WasmBuildError::Io {
4633 operation: "decode Cargo build artifacts",
4634 path: PathBuf::from(&spec.cargo_program),
4635 source,
4636 })?;
4637 return artifacts
4638 .into_iter()
4639 .collect::<Option<Vec<_>>>()
4640 .ok_or_else(|| WasmBuildError::MissingArtifacts {
4641 paths: expected_artifacts(spec, build_target_dir, targets),
4642 });
4643 }
4644 Err(WasmBuildError::CommandFailed {
4645 phase: WasmBuildPhase::CargoBuild,
4646 status,
4647 stdout: cargo_diagnostic(&evidence.stdout, evidence.stdout_truncated),
4648 stderr: cargo_diagnostic(&evidence.stderr, evidence.stderr_truncated),
4649 })
4650}
4651
4652fn collect_wasm_artifact(
4653 message: schema::Message,
4654 targets: &[WasmArtifactTarget],
4655 artifacts: &mut [Option<PathBuf>],
4656) -> io::Result<()> {
4657 let schema::Message::CompilerArtifact(artifact) = message else {
4658 return Ok(());
4659 };
4660 let Some((_, selected)) = targets.iter().zip(artifacts).find(|(target, _)| {
4662 artifact_matches(&artifact, &target.package_id, &target.name, &target.kind)
4663 }) else {
4664 return Ok(());
4665 };
4666 if !artifact
4667 .target
4668 .crate_types
4669 .contains(&schema::CrateType::CDyLib)
4670 {
4671 return Err(io::Error::new(
4672 io::ErrorKind::InvalidData,
4673 "Cargo reported a non-cdylib Wasm target",
4674 ));
4675 }
4676 let mut matching = wasm_artifact_paths(&artifact);
4677 let path = matching
4678 .next()
4679 .filter(|path| path.is_absolute())
4680 .ok_or_else(|| {
4681 io::Error::new(
4682 io::ErrorKind::InvalidData,
4683 "Cargo omitted absolute Wasm artifact path",
4684 )
4685 })?;
4686 if matching.next().is_some() || selected.replace(path.to_path_buf()).is_some() {
4687 return Err(io::Error::new(
4688 io::ErrorKind::InvalidData,
4689 "Cargo reported ambiguous Wasm artifacts",
4690 ));
4691 }
4692 Ok(())
4693}
4694
4695fn cargo_diagnostic(bytes: &[u8], truncated: bool) -> String {
4696 let mut text = String::from_utf8_lossy(bytes).into_owned();
4697 if truncated {
4698 text.push_str("\n[diagnostic truncated after 1 MiB; enable Cargo output events for the complete stream]\n");
4699 }
4700 text
4701}
4702
4703fn ensure_command_success(
4704 phase: WasmBuildPhase,
4705 output: CompletedExecution,
4706) -> Result<CompletedExecution, WasmBuildError> {
4707 if output.status.success() {
4708 return Ok(output);
4709 }
4710 Err(WasmBuildError::CommandFailed {
4711 phase,
4712 status: output.status,
4713 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
4714 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
4715 })
4716}
4717
4718fn artifact_relative_path(spec: &WasmBuildSpec, target: &WasmArtifactTarget) -> PathBuf {
4719 let profile = Path::new(&spec.target).join(
4720 spec.cargo_selection
4721 .profile_target_dir()
4722 .expect("Cargo profile was validated before artifact discovery"),
4723 );
4724 let directory = if spec.example.is_some() {
4725 profile.join("examples")
4726 } else {
4727 profile
4728 };
4729 directory.join(format!("{}.wasm", target.name.replace('-', "_")))
4730}
4731
4732fn expected_artifacts(
4733 spec: &WasmBuildSpec,
4734 target_dir: &Path,
4735 targets: &[WasmArtifactTarget],
4736) -> Vec<PathBuf> {
4737 targets
4738 .iter()
4739 .map(|target| target_dir.join(artifact_relative_path(spec, target)))
4740 .collect()
4741}
4742
4743fn artifact_publication_paths(
4744 spec: &WasmBuildSpec,
4745 cache_entry: &Path,
4746 targets: &[WasmArtifactTarget],
4747) -> Vec<(PathBuf, PathBuf)> {
4748 targets
4749 .iter()
4750 .map(|target| {
4751 let relative = artifact_relative_path(spec, target);
4752 (cache_entry.join(&relative), spec.target_dir.join(relative))
4753 })
4754 .collect()
4755}
4756
4757#[derive(Debug, Eq, PartialEq)]
4758struct VerifiedWasmArtifact {
4759 cached_path: PathBuf,
4760 public_path: PathBuf,
4761 info: FileDigest,
4762}
4763
4764fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
4765 spec.target_dir
4766 .join(".ic-testkit/wasm-targets")
4767 .join(fingerprint.to_hex())
4768}
4769
4770fn validated_artifact_set(
4771 artifacts: Vec<(PathBuf, PathBuf)>,
4772 fingerprint: InputDigest,
4773) -> Option<Vec<VerifiedWasmArtifact>> {
4774 let header = artifact_stamp_header(fingerprint);
4775 let stamp_len =
4777 header.len() + ARTIFACT_STAMP_DIGEST_LABEL.len() + fingerprint.as_bytes().len() * 2 + 1;
4778 artifacts
4779 .into_iter()
4780 .map(|(artifact, public_path)| {
4781 let metadata = fs::metadata(&artifact).ok()?;
4782 if !metadata.is_file() || metadata.len() == 0 {
4783 return None;
4784 }
4785 let stamp =
4786 read_stamp_with_limit(&artifact_stamp_path(&artifact), stamp_len).ok()??;
4787 if stamp.len() != stamp_len || !stamp.starts_with(&header) {
4790 return None;
4791 }
4792 let info = digest_file("wasm-artifact-v1", &artifact).ok()?;
4793 (stamp == artifact_stamp_contents(fingerprint, info.digest)).then_some(
4794 VerifiedWasmArtifact {
4795 cached_path: artifact,
4796 public_path,
4797 info,
4798 },
4799 )
4800 })
4801 .collect()
4802}
4803
4804fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
4805 artifacts
4806 .iter()
4807 .filter(|path| {
4808 fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
4809 })
4810 .cloned()
4811 .collect()
4812}
4813
4814fn artifact_stamp_path(artifact: &Path) -> PathBuf {
4815 let mut name = artifact
4816 .file_name()
4817 .map_or_else(|| OsString::from("artifact"), OsString::from);
4818 name.push(".ic-testkit-build");
4819 artifact.with_file_name(name)
4820}
4821
4822fn artifact_stamp_header(fingerprint: InputDigest) -> String {
4823 format!("{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\n")
4824}
4825
4826fn artifact_stamp_contents(fingerprint: InputDigest, artifact_digest: InputDigest) -> String {
4827 format!(
4828 "{}{ARTIFACT_STAMP_DIGEST_LABEL}{artifact_digest}\n",
4829 artifact_stamp_header(fingerprint),
4830 )
4831}
4832
4833fn write_artifact_stamp(
4834 artifact: &Path,
4835 fingerprint: InputDigest,
4836 info: &FileDigest,
4837 preserve_matching: bool,
4838) -> Result<(), WasmBuildError> {
4839 let stamp_path = artifact_stamp_path(artifact);
4840 let stamp = artifact_stamp_contents(fingerprint, info.digest);
4841 if preserve_matching && destination_matches_bytes(&stamp_path, stamp.as_bytes()) {
4842 return Ok(());
4843 }
4844 ic_host_fs::durable::write_bytes(
4845 &stamp_path,
4846 ic_host_fs::durable::WriteOptions {
4847 mode: ic_host_fs::durable::PublicationMode::Replace,
4848 permissions: 0o666,
4849 },
4850 stamp.as_bytes(),
4851 )
4852 .map_err(|source| WasmBuildError::Io {
4853 operation: "publish Wasm build stamp",
4854 path: stamp_path,
4855 source: io::Error::other(source),
4856 })
4857}
4858
4859fn publish_artifact_stamps(
4860 artifacts: Vec<(PathBuf, PathBuf)>,
4861 fingerprint: InputDigest,
4862) -> Result<Vec<VerifiedWasmArtifact>, WasmBuildError> {
4863 artifacts
4864 .into_iter()
4865 .map(|(cached_path, public_path)| {
4866 let info = digest_file("wasm-artifact-v1", &cached_path).map_err(|source| {
4867 WasmBuildError::Io {
4868 operation: "hash built Wasm artifact",
4869 path: cached_path.clone(),
4870 source,
4871 }
4872 })?;
4873 write_artifact_stamp(&cached_path, fingerprint, &info, false)?;
4874 Ok(VerifiedWasmArtifact {
4875 cached_path,
4876 public_path,
4877 info,
4878 })
4879 })
4880 .collect()
4881}
4882
4883fn materialize_artifacts(
4884 artifacts: &[VerifiedWasmArtifact],
4885 fingerprint: InputDigest,
4886 preserve_matching: bool,
4887) -> Result<(), WasmBuildError> {
4888 for artifact in artifacts {
4889 materialize_verified_output(
4890 "wasm-artifact-v1",
4891 &artifact.cached_path,
4892 &artifact.public_path,
4893 preserve_matching.then_some(&artifact.info),
4894 )
4895 .map_err(|source| WasmBuildError::Io {
4896 operation: "publish Wasm artifact",
4897 path: artifact.public_path.clone(),
4898 source,
4899 })?;
4900 }
4901 for artifact in artifacts {
4903 write_artifact_stamp(
4904 &artifact.public_path,
4905 fingerprint,
4906 &artifact.info,
4907 preserve_matching,
4908 )?;
4909 }
4910 Ok(())
4911}
4912
4913fn copy_wasm_artifacts(
4914 source_artifacts: &[PathBuf],
4915 destinations: &[(PathBuf, PathBuf)],
4916) -> Result<(), WasmBuildError> {
4917 if source_artifacts.len() != destinations.len() {
4919 return Err(invalid_metadata(
4920 "Cargo artifact count does not match admitted outputs",
4921 ));
4922 }
4923 for (index, (cached, _)) in destinations.iter().enumerate() {
4924 let source = &source_artifacts[index];
4925 if source != cached {
4926 copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
4927 operation: "cache shared-incremental Wasm artifact",
4928 path: cached.clone(),
4929 source: source_error,
4930 })?;
4931 }
4932 }
4933 Ok(())
4934}
4935
4936fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
4937 fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
4938 operation,
4939 path: path.to_owned(),
4940 source,
4941 })
4942}
4943
4944fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
4945 if path.exists() {
4946 inputs.push((PathBuf::from(label), path));
4947 }
4948}
4949
4950fn invalid_metadata(message: &str) -> WasmBuildError {
4951 WasmBuildError::InvalidMetadata {
4952 message: message.to_owned(),
4953 }
4954}
4955
4956impl WasmBuildError {
4957 fn indicates_input_change(&self) -> bool {
4958 match self {
4959 Self::InputsChangedDuringAcquisition { .. } => true,
4960 Self::FailedBuildCleanup { build_error, .. } => build_error.indicates_input_change(),
4961 _ => false,
4962 }
4963 }
4964}
4965
4966impl std::fmt::Display for WasmBuildError {
4967 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4968 match self {
4969 Self::InvalidSpec { message } => {
4970 write!(formatter, "invalid Wasm build spec: {message}")
4971 }
4972 Self::Io {
4973 operation,
4974 path,
4975 source,
4976 } => write!(
4977 formatter,
4978 "failed to {operation} at {}: {source}",
4979 path.display()
4980 ),
4981 Self::CommandSpawn {
4982 phase,
4983 program,
4984 source,
4985 } => write!(
4986 formatter,
4987 "failed to launch {phase} using `{}`: {source}",
4988 program.to_string_lossy(),
4989 ),
4990 Self::CommandFailed {
4991 phase,
4992 status,
4993 stdout,
4994 stderr,
4995 } => write!(
4996 formatter,
4997 "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
4998 ),
4999 Self::InvalidMetadata { message } => {
5000 write!(formatter, "invalid Cargo metadata: {message}")
5001 }
5002 Self::InvalidCargoConfiguration { path, message } => write!(
5003 formatter,
5004 "invalid Cargo configuration at {}: {message}",
5005 path.display(),
5006 ),
5007 Self::MissingArtifacts { paths } => {
5008 formatter.write_str("cargo build succeeded without producing: ")?;
5009 for (index, path) in paths.iter().enumerate() {
5010 if index != 0 {
5011 formatter.write_str(", ")?;
5012 }
5013 write!(formatter, "{}", path.display())?;
5014 }
5015 Ok(())
5016 }
5017 Self::InputsChangedDuringAcquisition { before, after } => write!(
5018 formatter,
5019 "Wasm inputs changed during artifact acquisition: {before} -> {after}",
5020 ),
5021 Self::PreparedInputSnapshotInvalidated => formatter.write_str(
5022 "the prepared Wasm input snapshot was invalidated before artifact publication",
5023 ),
5024 Self::FailedBuildCleanup {
5025 build_error,
5026 path,
5027 source,
5028 } => write!(
5029 formatter,
5030 "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
5031 path.display(),
5032 ),
5033 }
5034 }
5035}
5036
5037impl std::error::Error for WasmBuildError {
5038 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
5039 match self {
5040 Self::Io { source, .. }
5041 | Self::CommandSpawn { source, .. }
5042 | Self::FailedBuildCleanup { source, .. } => Some(source),
5043 _ => None,
5044 }
5045 }
5046}
5047
5048#[cfg(test)]
5049mod tests;