1use super::phase::WasmBuildPhase;
2use crate::cargo::{
3 execution::{
4 CargoInvocation, CargoRecipe, CargoTool, TOOL_IDENTITY_BYTES, communicate_cargo,
5 wasm_artifact_paths,
6 },
7 messages::artifact_matches,
8 observation::schema,
9 selection::CargoSelection,
10};
11use ic_host_fs::owned_directory::{OwnedDirectory, remove_path_if_present};
12use ic_host_fs::read::directory_logical_size;
13use ic_host_process::{
14 child::{CleanupPolicy, OwnedChild},
15 tool::{
16 CompletedExecution, ExecutionFailure, ExecutionOperation, OutputLimit, OutputLimits,
17 OutputStream, ToolError, capture_command, capture_group_command_with_cancellation,
18 },
19};
20use std::{
21 cell::RefCell,
22 collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
23 ffi::{OsStr, OsString},
24 fs::{self, File},
25 io,
26 path::{Component, Path, PathBuf},
27 process::{Command, ExitStatus, Stdio},
28 sync::{
29 Arc, RwLock,
30 atomic::{AtomicUsize, Ordering},
31 mpsc::{self, RecvTimeoutError},
32 },
33 thread,
34 time::{Duration, Instant, SystemTime},
35};
36use toml::Value as TomlValue;
37
38mod acquisition;
39use acquisition::prepare_exact_cache_entry;
40mod source_inputs;
41
42use super::{
43 cache_fs::{
44 ArtifactCacheMaintenance, ArtifactCachePruneReport, CacheFsError, CacheRetentionLimits,
45 RetainedCacheEntry, cache_entry_last_used, cache_maintenance_due,
46 canonicalize_allow_missing, ensure_cache_directory_tag as ensure_cache_tag,
47 is_sha256_directory, lock_cache_file, lock_cache_file_with_wait_observer,
48 perform_scheduled_cache_maintenance, prune_direct_child_directories,
49 record_cache_entry_use as record_entry_use, record_cache_maintenance,
50 remove_unretained_entry,
51 },
52 digest::{
53 FileDigest, InputDigest, InputHasher, LabeledPathDigestCache, copy_file_atomic,
54 destination_matches_bytes, digest_bytes, digest_file, digest_labeled_paths_composable,
55 materialize_verified_output, os_bytes, read_stamp_with_limit,
56 },
57};
58
59const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
60const BUILD_FINGERPRINT_DOMAIN: &str = "ic-build-explicit-environment-fingerprint";
62const ARTIFACT_STAMP_DIGEST_LABEL: &str = "artifact-sha256:";
63const CARGO_DIAGNOSTIC_BYTES: usize = 1024 * 1024;
65const CARGO_METADATA_BYTES: usize = 16 * 1024 * 1024;
66
67const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
68
69#[derive(Clone, Eq, PartialEq)]
77pub struct WasmBuildSpec {
78 workspace_root: PathBuf,
79 target_dir: PathBuf,
80 packages: Vec<String>,
81 example: Option<String>,
82 cargo_selection: CargoSelection,
83 environment: BTreeMap<OsString, OsString>,
84 additional_inputs: Vec<PathBuf>,
85 target: String,
86 cargo_program: OsString,
87 rustc_program: OsString,
88 cargo_message_bytes: usize,
89 cache_mode: WasmBuildCacheMode,
90 pruning: Option<(CacheRetentionLimits, Duration)>,
91 shared_incremental_maintenance_config: Option<SharedIncrementalTargetMaintenanceConfig>,
92}
93
94impl std::fmt::Debug for WasmBuildSpec {
95 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 formatter
97 .debug_struct("WasmBuildSpec")
98 .field("workspace_root", &self.workspace_root)
99 .field("target_dir", &self.target_dir)
100 .field("packages", &self.packages)
101 .field("example", &self.example)
102 .field("cargo_selection", &self.cargo_selection)
103 .field(
104 "environment_names",
105 &self.environment.keys().collect::<Vec<_>>(),
106 )
107 .field("additional_inputs", &self.additional_inputs)
108 .field("target", &self.target)
109 .field("cargo_program", &self.cargo_program)
110 .field("rustc_program", &self.rustc_program)
111 .field("cargo_message_bytes", &self.cargo_message_bytes)
112 .field("cache_mode", &self.cache_mode)
113 .field("pruning", &self.pruning)
114 .field(
115 "shared_incremental_maintenance_config",
116 &self.shared_incremental_maintenance_config,
117 )
118 .finish()
119 }
120}
121
122#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
124pub enum SharedIncrementalTargetMaintenanceFailureMode {
125 #[default]
127 Strict,
128 BestEffort,
130}
131
132#[derive(Clone, Copy, Debug, Eq, PartialEq)]
134pub struct SharedIncrementalTargetMaintenanceConfig {
135 policy: CacheRetentionLimits,
136 minimum_interval: Duration,
137 failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
138}
139
140#[non_exhaustive]
142#[derive(Clone, Debug, Eq, PartialEq)]
143pub enum WasmBuildCacheMode {
144 Isolated,
146 SharedIncremental {
148 target_dir: PathBuf,
150 },
151}
152
153#[derive(Clone, Debug, Eq, PartialEq)]
155pub enum WasmBuildOutcome {
156 Built(WasmBuildRecord),
158 Reused(WasmBuildRecord),
160}
161
162#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct WasmBuildRecord {
165 facts: CargoBuildFacts,
166 fingerprint: InputDigest,
167 input_digest: InputDigest,
168 retention: RetainedCacheEntry,
169 artifacts: Arc<[PathBuf]>,
170 timings: WasmBuildTimings,
171 maintenance: Option<ArtifactCacheMaintenance>,
172 shared_incremental_maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
173}
174
175#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
177pub struct WasmBuildTimings {
178 lock_wait: Duration,
179 shared_incremental_lock_wait: Option<Duration>,
180 input_resolution: WasmInputResolutionTimings,
181 cargo_build: Option<Duration>,
182 cache_maintenance: Option<Duration>,
183 total: Duration,
184}
185
186#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
188pub struct WasmInputResolutionTimings {
189 tool_identity: Duration,
190 cargo_metadata: Duration,
191 input_discovery: Duration,
192 content_hashing: Duration,
193 total: Duration,
194}
195
196#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
198pub struct WasmBuildFailureTimings {
199 exact_cache_coordination: Duration,
200 shared_target_coordination: Option<Duration>,
201 input_resolution: WasmInputResolutionTimings,
202 shared_target_maintenance: Option<Duration>,
203 cargo_build: Option<Duration>,
204 artifact_publication: Option<Duration>,
205 exact_cache_maintenance: Option<Duration>,
206 cleanup: Option<Duration>,
207 total: Duration,
208}
209
210#[derive(Clone, Copy, Debug, Eq, PartialEq)]
212pub struct WasmBuildFailureDetails {
213 phase: WasmBuildPhase,
214 timings: WasmBuildFailureTimings,
215}
216
217#[derive(Clone, Debug, Eq, PartialEq)]
219pub struct CargoBuildInput {
220 label: PathBuf,
221 path: PathBuf,
222}
223
224#[derive(Clone, Eq, PartialEq)]
232pub struct CargoBuildFacts {
233 pub selection: CargoSelection,
235 pub target: String,
237 pub environment: BTreeMap<OsString, OsString>,
240 pub cargo_identity: Vec<u8>,
242 pub rustc_identity: Vec<u8>,
244}
245
246impl std::fmt::Debug for CargoBuildFacts {
247 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248 f.debug_struct("CargoBuildFacts")
249 .field("selection", &self.selection)
250 .field("target", &self.target)
251 .field("environment_entries", &self.environment.len())
252 .field("cargo_identity_bytes", &self.cargo_identity.len())
253 .field("rustc_identity_bytes", &self.rustc_identity.len())
254 .finish()
255 }
256}
257
258#[derive(Clone, Debug, Eq, PartialEq)]
265pub struct ResolvedCargoBuildInputs {
266 facts: CargoBuildFacts,
267 fingerprint: InputDigest,
268 input_digest: InputDigest,
269 validation_digest: InputDigest,
270 inputs: Arc<[CargoBuildInput]>,
271 base_exclusions: Arc<[PathBuf]>,
273 declared_package_files: Arc<[PathBuf]>,
274 exclusions: Arc<[PathBuf]>,
275 wasm_outputs: Result<ResolvedWasmOutputs, String>,
276 timings: WasmInputResolutionTimings,
277}
278
279pub(super) struct WasmBuildBatchInputResolver<'a, 'session> {
280 specs: Vec<&'a WasmBuildSpec>,
281 groups: Vec<BatchResolutionGroup>,
282 group_by_index: Vec<usize>,
283 resolved: Vec<Option<Result<ResolvedCargoBuildInputs, (WasmBuildPhase, WasmBuildError)>>>,
284 reuse: Option<&'session WasmBuildInputSnapshotState>,
285 metrics: WasmBuildBatchInputMetrics,
286}
287
288pub(super) struct WasmBuildInputSnapshotState {
289 snapshots: Vec<(WasmBuildSpec, ResolvedCargoBuildInputs)>,
290 preparation_metrics: WasmBuildBatchInputMetrics,
291 preparation_timings: WasmInputResolutionTimings,
292 reader_reuses: AtomicUsize,
293 invalidation: Arc<RwLock<bool>>,
294}
295
296struct BatchResolutionGroup {
297 indexes: Vec<usize>,
298}
299
300#[derive(Clone, Debug, Eq, PartialEq)]
301struct WasmArtifactTarget {
302 package_id: String,
303 name: String,
304 kind: schema::TargetKind,
305}
306
307#[derive(Clone, Debug, Eq, PartialEq)]
308struct ResolvedWasmOutputs {
309 targets: Arc<[WasmArtifactTarget]>,
310 artifact_paths: Arc<[PathBuf]>,
311}
312
313struct ResolvedLocalInputs {
314 validation_inputs: Vec<(PathBuf, PathBuf)>,
315 base_exclusions: Vec<PathBuf>,
316 declared_package_files: Vec<PathBuf>,
317 workspace_projection: Option<InputDigest>,
318 wasm_targets: Result<Vec<WasmArtifactTarget>, String>,
319}
320
321#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
322pub(super) struct WasmBuildBatchInputMetrics {
323 pub(super) runs: usize,
324 pub(super) reuses: usize,
325 pub(super) prepared_reuses: usize,
326}
327
328#[derive(Eq, Hash, PartialEq)]
329struct BatchResolutionKey<'a> {
330 workspace_root: &'a Path,
331 cargo_program: &'a OsStr,
332 rustc_program: &'a OsStr,
333 metadata_arguments: Vec<OsString>,
334 environment: BTreeMap<OsString, OsString>,
335}
336
337#[derive(Clone, Debug, Eq, PartialEq)]
339pub struct SharedIncrementalTargetInspection {
340 target_dir: PathBuf,
341 logical_size_bytes: u64,
342 last_used: SystemTime,
343 lock_wait: Duration,
344}
345
346#[derive(Clone, Debug, Eq, PartialEq)]
348pub struct SharedIncrementalTargetMaintenance {
349 target_dir: PathBuf,
350 logical_size_bytes_before: u64,
351 logical_size_bytes_after: u64,
352 last_used_before: SystemTime,
353 cleared: bool,
354 lock_wait: Duration,
355 maintenance: Duration,
356}
357
358#[non_exhaustive]
360#[derive(Clone, Debug, Eq, PartialEq)]
361pub enum SharedIncrementalTargetMaintenanceOutcome {
362 Missing {
364 target_dir: PathBuf,
366 },
367 Skipped {
369 target_dir: PathBuf,
371 lock_wait: Duration,
373 schedule_check: Duration,
375 },
376 Performed {
378 maintenance: SharedIncrementalTargetMaintenance,
380 schedule_check: Duration,
382 },
383 Failed {
385 target_dir: PathBuf,
387 lock_wait: Duration,
389 message: String,
391 },
392}
393
394#[derive(Clone, Copy, Debug, Eq, PartialEq)]
396pub struct WasmBuildProgressConfig {
397 heartbeat_interval: Option<Duration>,
398 emit_cargo_output: bool,
399}
400
401#[derive(Clone, Copy, Debug, Eq, PartialEq)]
403pub enum WasmBuildProgressOutcome {
404 Built,
406 Reused,
408}
409
410#[non_exhaustive]
412#[derive(Clone, Debug, Eq, PartialEq)]
413pub enum WasmBuildProgressEvent {
414 Started,
416 InputsResolved {
418 fingerprint: InputDigest,
420 input_digest: InputDigest,
422 elapsed: Duration,
424 },
425 CacheMiss {
427 fingerprint: InputDigest,
429 },
430 CacheHit {
432 fingerprint: InputDigest,
434 },
435 SharedTargetLockStarted {
437 target_dir: PathBuf,
439 },
440 SharedTargetLockAcquired {
442 target_dir: PathBuf,
444 wait: Duration,
446 },
447 SharedTargetMaintenanceStarted {
449 target_dir: PathBuf,
451 },
452 SharedTargetMaintenanceFinished {
454 outcome: SharedIncrementalTargetMaintenanceOutcome,
456 },
457 CargoStarted {
459 target_dir: PathBuf,
461 },
462 CargoOutput {
464 stream: OutputStream,
466 bytes: Vec<u8>,
468 },
469 Heartbeat {
471 phase: WasmBuildPhase,
473 elapsed: Duration,
475 },
476 CargoFinished {
478 success: bool,
480 code: Option<i32>,
482 elapsed: Duration,
484 },
485 Finished {
487 outcome: WasmBuildProgressOutcome,
489 fingerprint: InputDigest,
491 elapsed: Duration,
493 },
494}
495
496impl Default for WasmBuildProgressConfig {
497 fn default() -> Self {
498 Self {
499 heartbeat_interval: Some(Duration::from_secs(10)),
500 emit_cargo_output: true,
501 }
502 }
503}
504
505impl WasmBuildProgressConfig {
506 #[must_use]
508 pub fn new() -> Self {
509 Self::default()
510 }
511
512 #[must_use]
516 pub const fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
517 self.heartbeat_interval = Some(interval);
518 self
519 }
520
521 #[must_use]
523 pub const fn without_heartbeats(mut self) -> Self {
524 self.heartbeat_interval = None;
525 self
526 }
527
528 #[must_use]
534 pub const fn with_cargo_output(mut self, emit: bool) -> Self {
535 self.emit_cargo_output = emit;
536 self
537 }
538
539 #[must_use]
541 pub const fn heartbeat_interval(self) -> Option<Duration> {
542 self.heartbeat_interval
543 }
544
545 #[must_use]
547 pub const fn emits_cargo_output(self) -> bool {
548 self.emit_cargo_output
549 }
550}
551
552struct ProgressReporter<'a> {
553 config: WasmBuildProgressConfig,
554 observer: Option<&'a mut dyn FnMut(WasmBuildProgressEvent)>,
555 last_event: Instant,
556 failure_phase: Option<WasmBuildPhase>,
557 failure_timings: WasmBuildFailureTimings,
558}
559
560impl<'a> ProgressReporter<'a> {
561 fn silent() -> Self {
562 Self {
563 config: WasmBuildProgressConfig {
564 heartbeat_interval: None,
565 emit_cargo_output: false,
566 },
567 observer: None,
568 last_event: Instant::now(),
569 failure_phase: None,
570 failure_timings: WasmBuildFailureTimings::default(),
571 }
572 }
573
574 fn new(
575 observation: Option<(
576 WasmBuildProgressConfig,
577 &'a mut dyn FnMut(WasmBuildProgressEvent),
578 )>,
579 ) -> Result<Self, WasmBuildError> {
580 let Some((config, observer)) = observation else {
581 return Ok(Self::silent());
582 };
583 if config.heartbeat_interval == Some(Duration::ZERO) {
584 return Err(WasmBuildError::InvalidSpec {
585 message: "Wasm build progress heartbeat interval must be greater than zero"
586 .to_owned(),
587 });
588 }
589 Ok(ProgressReporter {
590 config,
591 observer: Some(observer),
592 last_event: Instant::now(),
593 failure_phase: None,
594 failure_timings: WasmBuildFailureTimings::default(),
595 })
596 }
597
598 fn emit(&mut self, event: WasmBuildProgressEvent) {
599 if let Some(observer) = &mut self.observer {
600 observer(event);
601 self.last_event = Instant::now();
602 }
603 }
604
605 const fn is_observed(&self) -> bool {
606 self.observer.is_some()
607 }
608
609 fn heartbeat_due_in(&self) -> Option<Duration> {
610 self.config
611 .heartbeat_interval
612 .map(|interval| interval.saturating_sub(self.last_event.elapsed()))
613 }
614
615 fn emit_heartbeat(&mut self, phase: WasmBuildPhase, elapsed: Duration) {
616 self.emit(WasmBuildProgressEvent::Heartbeat { phase, elapsed });
617 }
618
619 fn emit_heartbeat_if_due(&mut self, phase: WasmBuildPhase, elapsed: Duration) {
620 if self.heartbeat_due_in() == Some(Duration::ZERO) {
621 self.emit_heartbeat(phase, elapsed);
622 }
623 }
624
625 fn run_phase<T, F>(&mut self, phase: WasmBuildPhase, operation: F) -> T
626 where
627 T: Send,
628 F: FnOnce() -> T + Send,
629 {
630 let started = Instant::now();
631 self.begin_phase(phase);
632 let result = if !self.is_observed() || self.config.heartbeat_interval.is_none() {
633 operation()
634 } else {
635 thread::scope(|scope| {
636 let (finished, completion) = mpsc::sync_channel(0);
637 let worker = scope.spawn(move || {
638 let result = operation();
639 let _ = finished.send(());
640 result
641 });
642 loop {
643 let wait = self
644 .heartbeat_due_in()
645 .expect("observed phase must have a heartbeat interval");
646 match completion.recv_timeout(wait) {
647 Ok(()) | Err(RecvTimeoutError::Disconnected) => {
648 return worker
649 .join()
650 .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
651 }
652 Err(RecvTimeoutError::Timeout) => {
653 self.emit_heartbeat(phase, started.elapsed());
654 }
655 }
656 }
657 })
658 };
659 self.record_phase(phase, started.elapsed());
660 result
661 }
662
663 const fn begin_phase(&mut self, phase: WasmBuildPhase) {
664 self.failure_phase = Some(phase);
665 }
666
667 fn record_phase(&mut self, phase: WasmBuildPhase, elapsed: Duration) {
668 self.failure_phase = Some(phase);
669 let timings = &mut self.failure_timings;
670 let timing = match phase {
673 WasmBuildPhase::Specification => return,
674 WasmBuildPhase::ExactCacheCoordination => &mut timings.exact_cache_coordination,
675 WasmBuildPhase::CargoIdentity | WasmBuildPhase::RustcIdentity => {
676 &mut timings.input_resolution.tool_identity
677 }
678 WasmBuildPhase::CargoMetadata => &mut timings.input_resolution.cargo_metadata,
679 WasmBuildPhase::InputDiscovery => &mut timings.input_resolution.input_discovery,
680 WasmBuildPhase::ContentHashing => &mut timings.input_resolution.content_hashing,
681 WasmBuildPhase::SharedTargetCoordination => timings
682 .shared_target_coordination
683 .get_or_insert(Duration::ZERO),
684 WasmBuildPhase::SharedTargetMaintenance => timings
685 .shared_target_maintenance
686 .get_or_insert(Duration::ZERO),
687 WasmBuildPhase::CargoBuild => timings.cargo_build.get_or_insert(Duration::ZERO),
688 WasmBuildPhase::ArtifactPublication => {
689 timings.artifact_publication.get_or_insert(Duration::ZERO)
690 }
691 WasmBuildPhase::ExactCacheMaintenance => timings
692 .exact_cache_maintenance
693 .get_or_insert(Duration::ZERO),
694 WasmBuildPhase::Cleanup => timings.cleanup.get_or_insert(Duration::ZERO),
695 };
696 *timing = timing.saturating_add(elapsed);
697 if matches!(
698 phase,
699 WasmBuildPhase::CargoIdentity
700 | WasmBuildPhase::RustcIdentity
701 | WasmBuildPhase::CargoMetadata
702 | WasmBuildPhase::InputDiscovery
703 | WasmBuildPhase::ContentHashing
704 ) {
705 timings.input_resolution.total = timings.input_resolution.total.saturating_add(elapsed);
706 }
707 }
708
709 fn failure_details(&self, error: &WasmBuildError, total: Duration) -> WasmBuildFailureDetails {
710 let phase = self
711 .failure_phase
712 .unwrap_or_else(|| classify_unobserved_failure(error));
713 let mut timings = self.failure_timings;
714 timings.total = total;
715 WasmBuildFailureDetails { phase, timings }
716 }
717}
718
719fn classify_unobserved_failure(error: &WasmBuildError) -> WasmBuildPhase {
720 match error {
721 WasmBuildError::InvalidSpec { .. } => WasmBuildPhase::Specification,
722 WasmBuildError::CommandSpawn { phase, .. }
723 | WasmBuildError::CommandFailed { phase, .. } => *phase,
724 WasmBuildError::InvalidMetadata { .. } => WasmBuildPhase::CargoMetadata,
725 WasmBuildError::InvalidCargoConfiguration { .. } => WasmBuildPhase::InputDiscovery,
726 WasmBuildError::MissingArtifacts { .. }
727 | WasmBuildError::PreparedInputSnapshotInvalidated => WasmBuildPhase::ArtifactPublication,
728 WasmBuildError::InputsChangedDuringAcquisition { .. } => WasmBuildPhase::ContentHashing,
729 WasmBuildError::FailedBuildCleanup { .. } => WasmBuildPhase::Cleanup,
730 WasmBuildError::Io { source, .. }
731 if source
732 .get_ref()
733 .is_some_and(<dyn std::error::Error + Send + Sync>::is::<ToolError>) =>
734 {
735 WasmBuildPhase::CargoBuild
736 }
737 WasmBuildError::Io { .. } => WasmBuildPhase::ExactCacheCoordination,
738 }
739}
740
741#[non_exhaustive]
743#[derive(Debug)]
744pub enum WasmBuildError {
745 InvalidSpec {
747 message: String,
749 },
750 Io {
752 operation: &'static str,
754 path: PathBuf,
756 source: io::Error,
758 },
759 CommandSpawn {
761 phase: WasmBuildPhase,
763 program: OsString,
765 source: io::Error,
767 },
768 CommandFailed {
772 phase: WasmBuildPhase,
774 status: ExitStatus,
776 stdout: String,
778 stderr: String,
780 },
781 InvalidMetadata {
783 message: String,
785 },
786 InvalidCargoConfiguration {
788 path: PathBuf,
790 message: String,
792 },
793 MissingArtifacts {
795 paths: Vec<PathBuf>,
797 },
798 InputsChangedDuringAcquisition {
800 before: InputDigest,
802 after: InputDigest,
804 },
805 PreparedInputSnapshotInvalidated,
807 FailedBuildCleanup {
809 build_error: Box<Self>,
811 path: PathBuf,
813 source: io::Error,
815 },
816}
817
818impl WasmBuildSpec {
819 #[must_use]
825 pub const fn with_cargo_message_bytes(mut self, maximum: usize) -> Self {
826 self.cargo_message_bytes = maximum;
827 self
828 }
829
830 #[must_use]
854 pub fn new<I, K, V>(
855 workspace_root: &Path,
856 target_dir: &Path,
857 packages: &[&str],
858 environment: I,
859 ) -> Self
860 where
861 I: IntoIterator<Item = (K, V)>,
862 K: Into<OsString>,
863 V: Into<OsString>,
864 {
865 let environment: BTreeMap<OsString, OsString> = environment
866 .into_iter()
867 .map(|(key, value)| (key.into(), value.into()))
868 .collect();
869 let mut packages = packages
870 .iter()
871 .map(|package| (*package).to_owned())
872 .collect::<Vec<_>>();
873 packages.sort();
874 packages.dedup();
875 Self {
876 workspace_root: workspace_root.to_owned(),
877 target_dir: target_dir.to_owned(),
878 packages,
879 example: None,
880 cargo_selection: CargoSelection::default(),
881 cargo_program: environment
882 .get(OsStr::new("CARGO"))
883 .cloned()
884 .unwrap_or_else(|| "cargo".into()),
885 rustc_program: environment
886 .get(OsStr::new("RUSTC"))
887 .cloned()
888 .unwrap_or_else(|| "rustc".into()),
889 environment,
890 additional_inputs: Vec::new(),
891 target: DEFAULT_TARGET.to_owned(),
892 cargo_message_bytes: CARGO_METADATA_BYTES,
893 cache_mode: WasmBuildCacheMode::SharedIncremental {
894 target_dir: PathBuf::from("target/ic-testkit-incremental"),
895 },
896 pruning: None,
897 shared_incremental_maintenance_config: None,
898 }
899 }
900
901 #[must_use]
907 pub fn with_example(mut self, name: impl Into<String>) -> Self {
908 self.example = Some(name.into());
909 self
910 }
911
912 #[must_use]
914 pub fn example(&self) -> Option<&str> {
915 self.example.as_deref()
916 }
917
918 pub fn cargo_build_command(&self) -> Result<Command, WasmBuildError> {
930 validate_spec(self)?;
931 cargo_build_command(self, &self.target_dir)
932 }
933
934 #[must_use]
939 pub fn with_cargo_selection(mut self, selection: CargoSelection) -> Self {
940 self.cargo_selection = selection;
941 self
942 }
943
944 #[must_use]
950 pub fn with_environment<I, K, V>(mut self, environment: I) -> Self
951 where
952 I: IntoIterator<Item = (K, V)>,
953 K: Into<OsString>,
954 V: Into<OsString>,
955 {
956 self.environment = environment
957 .into_iter()
958 .map(|(key, value)| (key.into(), value.into()))
959 .collect();
960 self
961 }
962
963 #[must_use]
965 pub const fn environment(&self) -> &BTreeMap<OsString, OsString> {
966 &self.environment
967 }
968
969 #[must_use]
974 pub fn with_additional_inputs<I, P>(mut self, paths: I) -> Self
975 where
976 I: IntoIterator<Item = P>,
977 P: Into<PathBuf>,
978 {
979 self.additional_inputs
980 .extend(paths.into_iter().map(Into::into));
981 self
982 }
983
984 #[must_use]
986 pub fn with_target(mut self, target: &str) -> Self {
987 target.clone_into(&mut self.target);
988 self
989 }
990
991 #[must_use]
993 pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
994 self.cargo_program = program.into();
995 self
996 }
997
998 #[must_use]
1006 pub fn with_rustc_identity_program(mut self, program: impl Into<OsString>) -> Self {
1007 self.rustc_program = program.into();
1008 self
1009 }
1010
1011 #[must_use]
1017 pub fn with_shared_incremental_target(mut self, target_dir: impl Into<PathBuf>) -> Self {
1018 self.cache_mode = WasmBuildCacheMode::SharedIncremental {
1019 target_dir: target_dir.into(),
1020 };
1021 self
1022 }
1023
1024 #[must_use]
1029 pub fn with_isolated_builds(mut self) -> Self {
1030 self.cache_mode = WasmBuildCacheMode::Isolated;
1031 self
1032 }
1033
1034 #[must_use]
1045 pub const fn with_shared_incremental_target_maintenance_at_most_every(
1046 mut self,
1047 policy: CacheRetentionLimits,
1048 minimum_interval: Duration,
1049 ) -> Self {
1050 self.shared_incremental_maintenance_config = Some(
1051 SharedIncrementalTargetMaintenanceConfig::new(policy, minimum_interval),
1052 );
1053 self
1054 }
1055
1056 #[must_use]
1062 pub const fn with_shared_incremental_target_maintenance(
1063 mut self,
1064 config: SharedIncrementalTargetMaintenanceConfig,
1065 ) -> Self {
1066 self.shared_incremental_maintenance_config = Some(config);
1067 self
1068 }
1069
1070 #[must_use]
1076 pub const fn with_prune_policy(mut self, policy: CacheRetentionLimits) -> Self {
1077 self.pruning = Some((policy, Duration::ZERO));
1078 self
1079 }
1080
1081 #[must_use]
1088 pub const fn with_prune_policy_at_most_every(
1089 mut self,
1090 policy: CacheRetentionLimits,
1091 minimum_interval: Duration,
1092 ) -> Self {
1093 self.pruning = Some((policy, minimum_interval));
1094 self
1095 }
1096
1097 #[must_use]
1099 pub fn workspace_root(&self) -> &Path {
1100 &self.workspace_root
1101 }
1102
1103 #[must_use]
1105 pub fn target_dir(&self) -> &Path {
1106 &self.target_dir
1107 }
1108
1109 #[must_use]
1111 pub fn packages(&self) -> &[String] {
1112 &self.packages
1113 }
1114
1115 #[must_use]
1117 pub const fn cache_mode(&self) -> &WasmBuildCacheMode {
1118 &self.cache_mode
1119 }
1120
1121 #[must_use]
1123 pub const fn prune_policy(&self) -> Option<CacheRetentionLimits> {
1124 match self.pruning {
1125 Some((policy, _)) => Some(policy),
1126 None => None,
1127 }
1128 }
1129
1130 #[must_use]
1135 pub const fn prune_interval(&self) -> Option<Duration> {
1136 match self.pruning {
1137 Some((_, interval)) if !interval.is_zero() => Some(interval),
1138 _ => None,
1139 }
1140 }
1141
1142 #[must_use]
1144 pub const fn shared_incremental_target_maintenance(
1145 &self,
1146 ) -> Option<SharedIncrementalTargetMaintenanceConfig> {
1147 self.shared_incremental_maintenance_config
1148 }
1149}
1150
1151impl WasmBuildOutcome {
1152 #[must_use]
1154 pub const fn record(&self) -> &WasmBuildRecord {
1155 match self {
1156 Self::Built(record) | Self::Reused(record) => record,
1157 }
1158 }
1159
1160 #[must_use]
1162 pub const fn is_reused(&self) -> bool {
1163 matches!(self, Self::Reused(_))
1164 }
1165}
1166
1167impl WasmBuildRecord {
1168 #[must_use]
1174 pub const fn facts(&self) -> &CargoBuildFacts {
1175 &self.facts
1176 }
1177
1178 #[must_use]
1180 pub const fn fingerprint(&self) -> InputDigest {
1181 self.fingerprint
1182 }
1183
1184 #[must_use]
1186 pub const fn input_digest(&self) -> InputDigest {
1187 self.input_digest
1188 }
1189
1190 #[must_use]
1197 pub fn exact_cache_path(&self) -> &Path {
1198 self.retention.path()
1199 }
1200
1201 #[must_use]
1206 pub fn artifacts(&self) -> &[PathBuf] {
1207 &self.artifacts
1208 }
1209
1210 #[must_use]
1212 pub const fn timings(&self) -> WasmBuildTimings {
1213 self.timings
1214 }
1215
1216 #[must_use]
1218 pub const fn maintenance(&self) -> Option<&ArtifactCacheMaintenance> {
1219 self.maintenance.as_ref()
1220 }
1221
1222 #[must_use]
1224 pub const fn shared_incremental_maintenance(
1225 &self,
1226 ) -> Option<&SharedIncrementalTargetMaintenanceOutcome> {
1227 self.shared_incremental_maintenance.as_ref()
1228 }
1229}
1230
1231impl WasmBuildTimings {
1232 #[must_use]
1234 pub const fn lock_wait(self) -> Duration {
1235 self.lock_wait
1236 }
1237
1238 #[must_use]
1240 pub const fn shared_incremental_lock_wait(self) -> Option<Duration> {
1241 self.shared_incremental_lock_wait
1242 }
1243
1244 #[must_use]
1246 pub const fn input_resolution(self) -> WasmInputResolutionTimings {
1247 self.input_resolution
1248 }
1249
1250 #[must_use]
1252 pub const fn cargo_build(self) -> Option<Duration> {
1253 self.cargo_build
1254 }
1255
1256 #[must_use]
1258 pub const fn cache_maintenance(self) -> Option<Duration> {
1259 self.cache_maintenance
1260 }
1261
1262 #[must_use]
1264 pub const fn total(self) -> Duration {
1265 self.total
1266 }
1267
1268 pub(super) fn saturating_add(self, other: Self) -> Self {
1269 let mut input_resolution = self.input_resolution;
1270 input_resolution.include(other.input_resolution);
1271 Self {
1272 lock_wait: self.lock_wait.saturating_add(other.lock_wait),
1273 shared_incremental_lock_wait: [
1274 self.shared_incremental_lock_wait,
1275 other.shared_incremental_lock_wait,
1276 ]
1277 .into_iter()
1278 .flatten()
1279 .reduce(Duration::saturating_add),
1280 input_resolution,
1281 cargo_build: [self.cargo_build, other.cargo_build]
1282 .into_iter()
1283 .flatten()
1284 .reduce(Duration::saturating_add),
1285 cache_maintenance: [self.cache_maintenance, other.cache_maintenance]
1286 .into_iter()
1287 .flatten()
1288 .reduce(Duration::saturating_add),
1289 total: self.total.saturating_add(other.total),
1290 }
1291 }
1292}
1293
1294impl WasmInputResolutionTimings {
1295 #[must_use]
1297 pub const fn tool_identity(self) -> Duration {
1298 self.tool_identity
1299 }
1300
1301 #[must_use]
1303 pub const fn cargo_metadata(self) -> Duration {
1304 self.cargo_metadata
1305 }
1306
1307 #[must_use]
1309 pub const fn input_discovery(self) -> Duration {
1310 self.input_discovery
1311 }
1312
1313 #[must_use]
1315 pub const fn content_hashing(self) -> Duration {
1316 self.content_hashing
1317 }
1318
1319 #[must_use]
1321 pub const fn total(self) -> Duration {
1322 self.total
1323 }
1324
1325 const fn include(&mut self, other: Self) {
1326 self.tool_identity = self.tool_identity.saturating_add(other.tool_identity);
1327 self.cargo_metadata = self.cargo_metadata.saturating_add(other.cargo_metadata);
1328 self.input_discovery = self.input_discovery.saturating_add(other.input_discovery);
1329 self.content_hashing = self.content_hashing.saturating_add(other.content_hashing);
1330 self.total = self.total.saturating_add(other.total);
1331 }
1332}
1333
1334impl WasmBuildFailureDetails {
1335 pub(super) fn specification(total: Duration) -> Self {
1336 Self {
1337 phase: WasmBuildPhase::Specification,
1338 timings: WasmBuildFailureTimings {
1339 total,
1340 ..WasmBuildFailureTimings::default()
1341 },
1342 }
1343 }
1344
1345 #[must_use]
1347 pub const fn phase(self) -> WasmBuildPhase {
1348 self.phase
1349 }
1350
1351 #[must_use]
1353 pub const fn timings(self) -> WasmBuildFailureTimings {
1354 self.timings
1355 }
1356}
1357
1358impl WasmBuildFailureTimings {
1359 #[must_use]
1361 pub const fn exact_cache_coordination(self) -> Duration {
1362 self.exact_cache_coordination
1363 }
1364
1365 #[must_use]
1367 pub const fn shared_target_coordination(self) -> Option<Duration> {
1368 self.shared_target_coordination
1369 }
1370
1371 #[must_use]
1373 pub const fn input_resolution(self) -> WasmInputResolutionTimings {
1374 self.input_resolution
1375 }
1376
1377 #[must_use]
1379 pub const fn shared_target_maintenance(self) -> Option<Duration> {
1380 self.shared_target_maintenance
1381 }
1382
1383 #[must_use]
1385 pub const fn cargo_build(self) -> Option<Duration> {
1386 self.cargo_build
1387 }
1388
1389 #[must_use]
1391 pub const fn artifact_publication(self) -> Option<Duration> {
1392 self.artifact_publication
1393 }
1394
1395 #[must_use]
1397 pub const fn exact_cache_maintenance(self) -> Option<Duration> {
1398 self.exact_cache_maintenance
1399 }
1400
1401 #[must_use]
1403 pub const fn cleanup(self) -> Option<Duration> {
1404 self.cleanup
1405 }
1406
1407 #[must_use]
1409 pub const fn total(self) -> Duration {
1410 self.total
1411 }
1412}
1413
1414impl CargoBuildInput {
1415 #[must_use]
1417 pub fn label(&self) -> &Path {
1418 &self.label
1419 }
1420
1421 #[must_use]
1426 pub fn path(&self) -> &Path {
1427 &self.path
1428 }
1429}
1430
1431pub struct CargoInputProbePolicy<'a> {
1438 pub deadline: Option<Instant>,
1441 pub cleanup: CleanupPolicy,
1443 pub cancelled: &'a mut (dyn FnMut() -> bool + Send),
1446}
1447
1448impl ResolvedCargoBuildInputs {
1449 #[must_use]
1451 pub const fn facts(&self) -> &CargoBuildFacts {
1452 &self.facts
1453 }
1454
1455 fn assemble(
1456 spec: &WasmBuildSpec,
1457 inputs: ResolvedLocalInputs,
1458 exclusions: Vec<PathBuf>,
1459 input_digest: InputDigest,
1460 validation_digest: InputDigest,
1461 facts: CargoBuildFacts,
1462 timings: impl FnOnce() -> WasmInputResolutionTimings,
1463 ) -> Self {
1464 Self {
1465 fingerprint: finish_build_fingerprint(spec, &facts, input_digest),
1466 facts,
1467 input_digest,
1468 validation_digest,
1469 inputs: cargo_build_inputs(inputs.validation_inputs),
1470 base_exclusions: inputs.base_exclusions.into(),
1471 declared_package_files: inputs.declared_package_files.into(),
1472 exclusions: exclusions.into(),
1473 wasm_outputs: inputs.wasm_targets.map(|targets| ResolvedWasmOutputs {
1474 artifact_paths: expected_artifacts(spec, &spec.target_dir, &targets).into(),
1475 targets: targets.into(),
1476 }),
1477 timings: timings(),
1479 }
1480 }
1481
1482 pub fn artifact_paths(&self) -> Result<&[PathBuf], WasmBuildError> {
1492 self.wasm_outputs()
1493 .map(|outputs| outputs.artifact_paths.as_ref())
1494 }
1495
1496 fn wasm_outputs(&self) -> Result<&ResolvedWasmOutputs, WasmBuildError> {
1497 self.wasm_outputs
1498 .as_ref()
1499 .map_err(|message| WasmBuildError::InvalidSpec {
1500 message: message.clone(),
1501 })
1502 }
1503
1504 #[must_use]
1506 pub const fn fingerprint(&self) -> InputDigest {
1507 self.fingerprint
1508 }
1509
1510 #[must_use]
1515 pub const fn input_digest(&self) -> InputDigest {
1516 self.input_digest
1517 }
1518
1519 #[must_use]
1524 pub const fn validation_digest(&self) -> InputDigest {
1525 self.validation_digest
1526 }
1527
1528 #[must_use]
1530 pub fn inputs(&self) -> &[CargoBuildInput] {
1531 &self.inputs
1532 }
1533
1534 #[must_use]
1541 pub fn exclusions(&self) -> &[PathBuf] {
1542 &self.exclusions
1543 }
1544
1545 #[must_use]
1547 pub const fn timings(&self) -> WasmInputResolutionTimings {
1548 self.timings
1549 }
1550
1551 pub fn is_current(&self, spec: &WasmBuildSpec) -> Result<bool, WasmBuildError> {
1556 resolve_cargo_build_inputs(spec).map(|current| current.fingerprint == self.fingerprint)
1557 }
1558
1559 pub fn is_current_with_probe_policy(
1567 &self,
1568 spec: &WasmBuildSpec,
1569 policy: &mut CargoInputProbePolicy<'_>,
1570 ) -> Result<bool, WasmBuildError> {
1571 resolve_cargo_build_inputs_with_probe_policy(spec, policy)
1572 .map(|current| current.fingerprint == self.fingerprint)
1573 }
1574
1575 pub fn is_content_current(&self) -> Result<bool, WasmBuildError> {
1585 self.current_validation_digest()
1586 .map(|current| current == self.validation_digest)
1587 }
1588
1589 pub(super) fn current_validation_digest(&self) -> Result<InputDigest, WasmBuildError> {
1590 let digest = || {
1591 let mut exclusions = self.base_exclusions.to_vec();
1592 source_inputs::exclude_nested_workspace_targets(
1593 self.inputs.iter().map(|input| input.path.as_path()),
1594 &self.declared_package_files,
1595 &mut exclusions,
1596 )?;
1597 digest_labeled_paths_composable(
1598 "wasm-source-inputs-v1",
1599 self.inputs
1600 .iter()
1601 .map(|input| (input.label.as_path(), input.path.as_path())),
1602 &exclusions,
1603 &mut LabeledPathDigestCache::default(),
1604 )
1605 };
1606 digest().map_err(|source| WasmBuildError::Io {
1607 operation: "rehash resolved Cargo build inputs",
1608 path: self
1609 .inputs
1610 .first()
1611 .map_or_else(PathBuf::new, |input| input.path.clone()),
1612 source,
1613 })
1614 }
1615}
1616
1617impl WasmBuildInputSnapshotState {
1618 pub(super) fn prepare(specs: &[WasmBuildSpec]) -> Result<Self, WasmBuildError> {
1619 for spec in specs {
1620 validate_spec(spec)?;
1621 }
1622 let mut resolver = WasmBuildBatchInputResolver::new(specs, None)
1623 .map_err(|_| WasmBuildError::PreparedInputSnapshotInvalidated)?;
1624 let mut snapshots = Vec::with_capacity(specs.len());
1625 let mut preparation_timings = WasmInputResolutionTimings::default();
1626 let mut progress = ProgressReporter::silent();
1627 for (index, spec) in specs.iter().enumerate() {
1628 let mut prepared_input = resolver.resolve(index, &mut progress)?;
1629 preparation_timings.include(prepared_input.timings);
1630 prepared_input.timings = WasmInputResolutionTimings::default();
1631 snapshots.push((spec.clone(), prepared_input));
1632 }
1633 Ok(Self {
1634 snapshots,
1635 preparation_metrics: resolver.metrics(),
1636 preparation_timings,
1637 reader_reuses: AtomicUsize::new(0),
1638 invalidation: Arc::new(RwLock::new(false)),
1639 })
1640 }
1641
1642 fn admit(&self, specs: &[&WasmBuildSpec]) -> Result<Vec<ResolvedCargoBuildInputs>, usize> {
1643 let matched = specs
1644 .iter()
1645 .enumerate()
1646 .map(|(index, spec)| {
1647 self.snapshots
1648 .iter()
1649 .find(|(candidate, _)| candidate == *spec)
1650 .map(|(_, snapshot)| snapshot.clone())
1651 .ok_or(index)
1652 })
1653 .collect::<Result<Vec<_>, _>>()?;
1654 #[allow(deprecated, reason = "try_update is unstable at the Rust 1.91 MSRV")]
1655 let _ = self
1656 .reader_reuses
1657 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
1658 Some(current.saturating_add(matched.len()))
1659 });
1660 Ok(matched)
1661 }
1662
1663 pub(super) const fn specification_count(&self) -> usize {
1664 self.snapshots.len()
1665 }
1666
1667 pub(super) const fn preparation_metrics(&self) -> WasmBuildBatchInputMetrics {
1668 self.preparation_metrics
1669 }
1670
1671 pub(super) const fn preparation_timings(&self) -> WasmInputResolutionTimings {
1672 self.preparation_timings
1673 }
1674
1675 pub(super) fn reader_reuses(&self) -> usize {
1676 self.reader_reuses.load(Ordering::Relaxed)
1677 }
1678
1679 pub(super) fn is_invalidated(&self) -> bool {
1680 *self
1681 .invalidation
1682 .read()
1683 .unwrap_or_else(std::sync::PoisonError::into_inner)
1684 }
1685
1686 fn invalidate(&self) {
1687 *self
1688 .invalidation
1689 .write()
1690 .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
1691 }
1692
1693 fn invalidation(&self) -> Arc<RwLock<bool>> {
1694 Arc::clone(&self.invalidation)
1695 }
1696}
1697
1698impl<'a, 'session> WasmBuildBatchInputResolver<'a, 'session> {
1699 pub(super) fn new(
1700 specs: impl IntoIterator<Item = &'a WasmBuildSpec>,
1701 reuse: Option<&'session WasmBuildInputSnapshotState>,
1702 ) -> Result<Self, usize> {
1703 let specs = specs.into_iter().collect::<Vec<_>>();
1704 let prepared = match reuse.as_ref() {
1705 Some(snapshot) => Some(snapshot.admit(&specs)?),
1706 _ => None,
1707 };
1708 let mut groups_by_key = HashMap::new();
1709 let mut groups = Vec::<BatchResolutionGroup>::new();
1710 let mut group_by_index = Vec::with_capacity(specs.len());
1711 for (index, spec) in specs.iter().copied().enumerate() {
1712 let group = *groups_by_key
1713 .entry(BatchResolutionKey::for_spec(spec))
1714 .or_insert_with(|| {
1715 groups.push(BatchResolutionGroup {
1716 indexes: Vec::new(),
1717 });
1718 groups.len() - 1
1719 });
1720 groups[group].indexes.push(index);
1721 group_by_index.push(group);
1722 }
1723 let mut metrics = WasmBuildBatchInputMetrics::default();
1724 let resolved = prepared.map_or_else(
1725 || specs.iter().map(|_| None).collect(),
1726 |prepared| {
1727 metrics.prepared_reuses = prepared.len();
1728 prepared
1729 .into_iter()
1730 .map(|snapshot| Some(Ok(snapshot)))
1731 .collect()
1732 },
1733 );
1734 Ok(Self {
1735 specs,
1736 groups,
1737 group_by_index,
1738 resolved,
1739 reuse,
1740 metrics,
1741 })
1742 }
1743
1744 pub(super) const fn metrics(&self) -> WasmBuildBatchInputMetrics {
1745 self.metrics
1746 }
1747
1748 pub(super) fn invalidate_source_lease(&self) {
1749 if let Some(snapshot) = self.reuse {
1750 snapshot.invalidate();
1751 }
1752 }
1753
1754 pub(super) fn prepared_invalidation(&self) -> Option<Arc<RwLock<bool>>> {
1755 self.reuse.map(WasmBuildInputSnapshotState::invalidation)
1756 }
1757
1758 fn resolve(
1759 &mut self,
1760 index: usize,
1761 progress: &mut ProgressReporter<'_>,
1762 ) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1763 if self.resolved[index].is_none() {
1764 self.resolve_group(index, progress)?;
1765 }
1766 self.resolved[index]
1767 .take()
1768 .expect("resolved batch input must be populated")
1769 .map_err(|(phase, error)| {
1770 progress.begin_phase(phase);
1771 error
1772 })
1773 }
1774
1775 fn resolve_group(
1776 &mut self,
1777 active_index: usize,
1778 progress: &mut ProgressReporter<'_>,
1779 ) -> Result<(), WasmBuildError> {
1780 let total_started = Instant::now();
1781 let group = self.group_by_index[active_index];
1782 let active = self.specs[active_index];
1783
1784 let (cargo_identity, rustc_identity, tool_identity) =
1785 resolve_tool_identity(active, progress)?;
1786
1787 let metadata_started = Instant::now();
1788 let metadata =
1789 progress.run_phase(WasmBuildPhase::CargoMetadata, || cargo_metadata(active))?;
1790 let cargo_metadata = metadata_started.elapsed();
1791
1792 let (discovered, input_discovery) = self.discover_group_inputs(group, &metadata, progress);
1793
1794 let hashing_started = Instant::now();
1795 let mut batch_digest_cache = LabeledPathDigestCache::default();
1796 let digest_cache = &mut batch_digest_cache;
1797 let workspace_root = &active.workspace_root;
1798 let resolved_inputs = progress.run_phase(WasmBuildPhase::ContentHashing, || {
1799 discovered
1800 .into_iter()
1801 .map(|(index, inputs, mut exclusions)| {
1802 let result = digest_resolved_local_inputs(
1803 &inputs,
1804 &mut exclusions,
1805 digest_cache,
1806 workspace_root,
1807 "hash batched Wasm build inputs",
1808 "hash batched semantic Wasm build inputs",
1809 )
1810 .map(|(input_digest, validation_digest)| {
1811 (inputs, exclusions, input_digest, validation_digest)
1812 });
1813 (index, result)
1814 })
1815 .collect::<Vec<_>>()
1816 });
1817 let content_hashing = hashing_started.elapsed();
1818 let timings = WasmInputResolutionTimings {
1819 tool_identity,
1820 cargo_metadata,
1821 input_discovery,
1822 content_hashing,
1823 total: total_started.elapsed(),
1824 };
1825 let resolved_count = resolved_inputs
1826 .iter()
1827 .filter(|(_, result)| result.is_ok())
1828 .count();
1829 self.metrics.runs += usize::from(resolved_count > 0);
1830 self.metrics.reuses += resolved_count.saturating_sub(1);
1831 let timing_index = resolved_inputs
1832 .iter()
1833 .find(|(index, result)| *index == active_index && result.is_ok())
1834 .or_else(|| resolved_inputs.iter().find(|(_, result)| result.is_ok()))
1835 .map(|(index, _)| *index);
1836 for (index, result) in resolved_inputs {
1837 let (inputs, exclusions, input_digest, validation_digest) = match result {
1838 Ok(resolved) => resolved,
1839 Err(error) => {
1840 self.resolved[index] = Some(Err((WasmBuildPhase::ContentHashing, error)));
1841 continue;
1842 }
1843 };
1844 let spec = self.specs[index];
1845 let resolved = ResolvedCargoBuildInputs::assemble(
1846 spec,
1847 inputs,
1848 exclusions,
1849 input_digest,
1850 validation_digest,
1851 spec.cargo_invocation()
1852 .facts(cargo_identity.clone(), rustc_identity.clone()),
1853 || {
1854 if Some(index) == timing_index {
1855 timings
1856 } else {
1857 WasmInputResolutionTimings::default()
1858 }
1859 },
1860 );
1861 self.resolved[index] = Some(Ok(resolved));
1862 }
1863 Ok(())
1864 }
1865
1866 fn discover_group_inputs(
1867 &mut self,
1868 group: usize,
1869 metadata: &schema::Metadata,
1870 progress: &mut ProgressReporter<'_>,
1871 ) -> (Vec<(usize, ResolvedLocalInputs, Vec<PathBuf>)>, Duration) {
1872 let started = Instant::now();
1873 let indexes = &self.groups[group].indexes;
1874 let specs = &self.specs;
1875 let resolved = &mut self.resolved;
1876 let discovered = progress.run_phase(WasmBuildPhase::InputDiscovery, || {
1877 let parsed = ParsedCargoMetadata::parse(metadata);
1878 let mut discovered = Vec::new();
1879 for &index in indexes {
1880 if resolved[index].is_some() || validate_spec(specs[index]).is_err() {
1881 continue;
1882 }
1883 let result = parsed
1884 .as_ref()
1885 .map_err(|message| invalid_metadata(message))
1886 .and_then(|parsed| resolve_local_inputs(specs[index], parsed));
1887 match result {
1888 Ok((inputs, exclusions)) => discovered.push((index, inputs, exclusions)),
1889 Err(error) => {
1890 resolved[index] = Some(Err((WasmBuildPhase::InputDiscovery, error)));
1891 }
1892 }
1893 }
1894 discovered
1895 });
1896 (discovered, started.elapsed())
1897 }
1898}
1899
1900fn resolve_tool_identity(
1901 spec: &WasmBuildSpec,
1902 progress: &mut ProgressReporter<'_>,
1903) -> Result<(Vec<u8>, Vec<u8>, Duration), WasmBuildError> {
1904 resolve_tool_identity_with_policy(spec, progress, &mut None)
1905}
1906
1907fn resolve_tool_identity_with_policy(
1908 spec: &WasmBuildSpec,
1909 progress: &mut ProgressReporter<'_>,
1910 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
1911) -> Result<(Vec<u8>, Vec<u8>, Duration), WasmBuildError> {
1912 let started = Instant::now();
1913 let facts = spec
1914 .cargo_invocation()
1915 .observe(&spec.rustc_program, |tool, command| {
1916 let phase = match tool {
1917 CargoTool::Cargo => WasmBuildPhase::CargoIdentity,
1918 CargoTool::Rustc => WasmBuildPhase::RustcIdentity,
1919 };
1920 progress.run_phase(phase, || {
1921 let output =
1922 capture_probe_output_with_policy(command, phase, TOOL_IDENTITY_BYTES, policy)?;
1923 ensure_command_success(phase, output)
1924 })
1925 })?;
1926 Ok((
1927 facts.cargo_identity,
1928 facts.rustc_identity,
1929 started.elapsed(),
1930 ))
1931}
1932
1933impl<'a> BatchResolutionKey<'a> {
1934 fn for_spec(spec: &'a WasmBuildSpec) -> Self {
1935 Self {
1936 workspace_root: &spec.workspace_root,
1937 cargo_program: &spec.cargo_program,
1938 rustc_program: spec
1939 .environment
1940 .get(OsStr::new("RUSTC"))
1941 .unwrap_or(&spec.rustc_program),
1942 metadata_arguments: spec.cargo_selection.metadata_arguments(),
1943 environment: spec.environment.clone(),
1944 }
1945 }
1946}
1947
1948impl SharedIncrementalTargetInspection {
1949 #[must_use]
1951 pub fn target_dir(&self) -> &Path {
1952 &self.target_dir
1953 }
1954
1955 #[must_use]
1957 pub const fn logical_size_bytes(&self) -> u64 {
1958 self.logical_size_bytes
1959 }
1960
1961 #[must_use]
1963 pub const fn last_used(&self) -> SystemTime {
1964 self.last_used
1965 }
1966
1967 #[must_use]
1969 pub const fn lock_wait(&self) -> Duration {
1970 self.lock_wait
1971 }
1972}
1973
1974impl SharedIncrementalTargetMaintenanceConfig {
1975 #[must_use]
1977 pub const fn new(policy: CacheRetentionLimits, minimum_interval: Duration) -> Self {
1978 Self {
1979 policy,
1980 minimum_interval,
1981 failure_mode: SharedIncrementalTargetMaintenanceFailureMode::Strict,
1982 }
1983 }
1984
1985 #[must_use]
1987 pub const fn with_failure_mode(
1988 mut self,
1989 failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
1990 ) -> Self {
1991 self.failure_mode = failure_mode;
1992 self
1993 }
1994
1995 #[must_use]
1997 pub const fn policy(self) -> CacheRetentionLimits {
1998 self.policy
1999 }
2000
2001 #[must_use]
2003 pub const fn minimum_interval(self) -> Duration {
2004 self.minimum_interval
2005 }
2006
2007 #[must_use]
2009 pub const fn failure_mode(self) -> SharedIncrementalTargetMaintenanceFailureMode {
2010 self.failure_mode
2011 }
2012}
2013
2014impl SharedIncrementalTargetMaintenance {
2015 #[must_use]
2017 pub fn target_dir(&self) -> &Path {
2018 &self.target_dir
2019 }
2020
2021 #[must_use]
2023 pub const fn logical_size_bytes_before(&self) -> u64 {
2024 self.logical_size_bytes_before
2025 }
2026
2027 #[must_use]
2029 pub const fn logical_size_bytes_after(&self) -> u64 {
2030 self.logical_size_bytes_after
2031 }
2032
2033 #[must_use]
2035 pub const fn last_used_before(&self) -> SystemTime {
2036 self.last_used_before
2037 }
2038
2039 #[must_use]
2041 pub const fn was_cleared(&self) -> bool {
2042 self.cleared
2043 }
2044
2045 #[must_use]
2047 pub const fn lock_wait(&self) -> Duration {
2048 self.lock_wait
2049 }
2050
2051 #[must_use]
2053 pub const fn maintenance(&self) -> Duration {
2054 self.maintenance
2055 }
2056}
2057
2058impl std::fmt::Display for SharedIncrementalTargetMaintenance {
2059 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2060 write!(
2061 formatter,
2062 "target={} action={} bytes={}=>{} lock={:?} maintenance={:?}",
2063 self.target_dir.display(),
2064 if self.cleared { "cleared" } else { "retained" },
2065 self.logical_size_bytes_before,
2066 self.logical_size_bytes_after,
2067 self.lock_wait,
2068 self.maintenance,
2069 )
2070 }
2071}
2072
2073impl SharedIncrementalTargetMaintenanceOutcome {
2074 #[must_use]
2076 pub fn target_dir(&self) -> &Path {
2077 match self {
2078 Self::Missing { target_dir }
2079 | Self::Skipped { target_dir, .. }
2080 | Self::Failed { target_dir, .. } => target_dir,
2081 Self::Performed { maintenance, .. } => maintenance.target_dir(),
2082 }
2083 }
2084
2085 #[must_use]
2087 pub const fn maintenance(&self) -> Option<&SharedIncrementalTargetMaintenance> {
2088 match self {
2089 Self::Performed { maintenance, .. } => Some(maintenance),
2090 Self::Missing { .. } | Self::Skipped { .. } | Self::Failed { .. } => None,
2091 }
2092 }
2093
2094 #[must_use]
2096 pub const fn was_performed(&self) -> bool {
2097 matches!(self, Self::Performed { .. })
2098 }
2099
2100 #[must_use]
2102 pub const fn lock_wait(&self) -> Option<Duration> {
2103 match self {
2104 Self::Missing { .. } => None,
2105 Self::Skipped { lock_wait, .. } | Self::Failed { lock_wait, .. } => Some(*lock_wait),
2106 Self::Performed { maintenance, .. } => Some(maintenance.lock_wait()),
2107 }
2108 }
2109
2110 #[must_use]
2112 pub const fn schedule_check(&self) -> Option<Duration> {
2113 match self {
2114 Self::Missing { .. } | Self::Failed { .. } => None,
2115 Self::Skipped { schedule_check, .. } | Self::Performed { schedule_check, .. } => {
2116 Some(*schedule_check)
2117 }
2118 }
2119 }
2120
2121 #[must_use]
2123 pub fn failure_message(&self) -> Option<&str> {
2124 match self {
2125 Self::Failed { message, .. } => Some(message),
2126 Self::Missing { .. } | Self::Skipped { .. } | Self::Performed { .. } => None,
2127 }
2128 }
2129}
2130
2131impl std::fmt::Display for SharedIncrementalTargetMaintenanceOutcome {
2132 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2133 match self {
2134 Self::Missing { target_dir } => {
2135 write!(formatter, "target={} action=missing", target_dir.display())
2136 }
2137 Self::Skipped {
2138 target_dir,
2139 lock_wait,
2140 schedule_check,
2141 } => write!(
2142 formatter,
2143 "target={} action=skipped lock={lock_wait:?} schedule={schedule_check:?}",
2144 target_dir.display(),
2145 ),
2146 Self::Performed {
2147 maintenance,
2148 schedule_check,
2149 } => write!(formatter, "{maintenance} schedule={schedule_check:?}"),
2150 Self::Failed {
2151 target_dir,
2152 lock_wait,
2153 message,
2154 } => write!(
2155 formatter,
2156 "target={} action=failed lock={lock_wait:?} error={message}",
2157 target_dir.display(),
2158 ),
2159 }
2160 }
2161}
2162
2163impl std::fmt::Display for WasmBuildTimings {
2164 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2165 write!(
2166 formatter,
2167 "total={:?} lock={:?} shared_lock={:?} inputs={:?} cargo={:?} maintenance={:?}",
2168 self.total,
2169 self.lock_wait,
2170 self.shared_incremental_lock_wait,
2171 self.input_resolution.total,
2172 self.cargo_build,
2173 self.cache_maintenance,
2174 )
2175 }
2176}
2177
2178impl std::fmt::Display for WasmBuildOutcome {
2179 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2180 let state = if self.is_reused() { "reused" } else { "built" };
2181 write!(
2182 formatter,
2183 "{state} fingerprint={} artifacts={} {}",
2184 self.record().fingerprint,
2185 self.record().artifacts.len(),
2186 self.record().timings,
2187 )?;
2188 if let Some(maintenance) = self.record().shared_incremental_maintenance() {
2189 write!(formatter, " shared_maintenance=({maintenance})")?;
2190 }
2191 Ok(())
2192 }
2193}
2194
2195pub fn resolve_cargo_build_inputs(
2209 spec: &WasmBuildSpec,
2210) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2211 validate_spec(spec)?;
2212 build_fingerprint(spec)
2213}
2214
2215pub fn resolve_cargo_build_inputs_with_probe_policy(
2227 spec: &WasmBuildSpec,
2228 policy: &mut CargoInputProbePolicy<'_>,
2229) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2230 validate_spec(spec)?;
2231 build_fingerprint_with_policy(spec, &mut ProgressReporter::silent(), &mut Some(policy))
2232}
2233
2234pub fn inspect_shared_incremental_target(
2242 spec: &WasmBuildSpec,
2243) -> Result<Option<SharedIncrementalTargetInspection>, WasmBuildError> {
2244 if !shared_incremental_target_exists(spec, "inspect shared incremental Cargo target")? {
2245 return Ok(None);
2246 }
2247
2248 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2249 let logical_size_bytes =
2250 directory_logical_size(&canonical).map_err(|source| WasmBuildError::Io {
2251 operation: "measure shared incremental Cargo target",
2252 path: canonical.clone(),
2253 source,
2254 })?;
2255 let last_used = cache_entry_last_used(&canonical).map_err(|source| WasmBuildError::Io {
2256 operation: "read shared incremental Cargo target use time",
2257 path: canonical.clone(),
2258 source,
2259 })?;
2260 Ok(Some(SharedIncrementalTargetInspection {
2261 target_dir: canonical,
2262 logical_size_bytes,
2263 last_used,
2264 lock_wait,
2265 }))
2266}
2267
2268pub fn maintain_shared_incremental_target(
2286 spec: &WasmBuildSpec,
2287 policy: CacheRetentionLimits,
2288) -> Result<Option<SharedIncrementalTargetMaintenance>, WasmBuildError> {
2289 if !shared_incremental_target_exists(
2290 spec,
2291 "inspect shared incremental Cargo target before maintenance",
2292 )? {
2293 return Ok(None);
2294 }
2295
2296 let _ = resolve_cargo_build_inputs(spec)?;
2300 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2301 maintain_shared_incremental_target_locked(&canonical, policy, lock_wait).map(Some)
2302}
2303
2304pub fn maintain_shared_incremental_target_at_most_every(
2322 spec: &WasmBuildSpec,
2323 policy: CacheRetentionLimits,
2324 minimum_interval: Duration,
2325) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2326 let target_dir =
2327 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
2328 message: "shared incremental target is not configured".to_owned(),
2329 })?;
2330 if !shared_incremental_target_exists(
2331 spec,
2332 "inspect shared incremental Cargo target before scheduled maintenance",
2333 )? {
2334 return Ok(SharedIncrementalTargetMaintenanceOutcome::Missing { target_dir });
2335 }
2336
2337 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2338 let schedule = schedule_shared_incremental_target_maintenance(
2339 &canonical,
2340 policy,
2341 minimum_interval,
2342 lock_wait,
2343 )?;
2344 let schedule = match schedule {
2345 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => return Ok(outcome),
2346 SharedIncrementalTargetMaintenanceSchedule::Due(due) => due,
2347 };
2348
2349 let _ = resolve_cargo_build_inputs(spec)?;
2352 perform_due_shared_incremental_target_maintenance(&canonical, policy, lock_wait, schedule)
2353}
2354
2355enum SharedIncrementalTargetMaintenanceSchedule {
2356 Skipped(SharedIncrementalTargetMaintenanceOutcome),
2357 Due(DueSharedIncrementalTargetMaintenance),
2358}
2359
2360struct DueSharedIncrementalTargetMaintenance {
2361 schedule_root: PathBuf,
2362 maintenance_identity: String,
2363 schedule_check: Duration,
2364}
2365
2366fn schedule_shared_incremental_target_maintenance(
2367 canonical: &Path,
2368 policy: CacheRetentionLimits,
2369 minimum_interval: Duration,
2370 lock_wait: Duration,
2371) -> Result<SharedIncrementalTargetMaintenanceSchedule, WasmBuildError> {
2372 let schedule_root = canonical.join(".ic-testkit");
2373 let maintenance_identity = policy.maintenance_identity();
2374 let schedule_started = Instant::now();
2375 let due = cache_maintenance_due(&schedule_root, minimum_interval, &maintenance_identity)
2376 .map_err(wasm_cache_fs_error)?;
2377 let schedule_check = schedule_started.elapsed();
2378 if !due {
2379 return Ok(SharedIncrementalTargetMaintenanceSchedule::Skipped(
2380 SharedIncrementalTargetMaintenanceOutcome::Skipped {
2381 target_dir: canonical.to_owned(),
2382 lock_wait,
2383 schedule_check,
2384 },
2385 ));
2386 }
2387 Ok(SharedIncrementalTargetMaintenanceSchedule::Due(
2388 DueSharedIncrementalTargetMaintenance {
2389 schedule_root,
2390 maintenance_identity,
2391 schedule_check,
2392 },
2393 ))
2394}
2395
2396fn perform_due_shared_incremental_target_maintenance(
2397 canonical: &Path,
2398 policy: CacheRetentionLimits,
2399 lock_wait: Duration,
2400 due: DueSharedIncrementalTargetMaintenance,
2401) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2402 let DueSharedIncrementalTargetMaintenance {
2403 schedule_root,
2404 maintenance_identity,
2405 schedule_check,
2406 } = due;
2407 let maintenance = maintain_shared_incremental_target_locked(canonical, policy, lock_wait)?;
2408 record_cache_maintenance(&schedule_root, &maintenance_identity).map_err(wasm_cache_fs_error)?;
2409 Ok(SharedIncrementalTargetMaintenanceOutcome::Performed {
2410 maintenance,
2411 schedule_check,
2412 })
2413}
2414
2415fn maintain_shared_incremental_target_locked(
2416 canonical: &Path,
2417 policy: CacheRetentionLimits,
2418 lock_wait: Duration,
2419) -> Result<SharedIncrementalTargetMaintenance, WasmBuildError> {
2420 let started = Instant::now();
2421 let logical_size_bytes_before =
2422 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2423 operation: "measure shared incremental Cargo target before maintenance",
2424 path: canonical.to_owned(),
2425 source,
2426 })?;
2427 let last_used_before =
2428 cache_entry_last_used(canonical).map_err(|source| WasmBuildError::Io {
2429 operation: "read shared incremental Cargo target use time before maintenance",
2430 path: canonical.to_owned(),
2431 source,
2432 })?;
2433 let expired = policy.max_age().is_some_and(|max_age| {
2434 SystemTime::now()
2435 .duration_since(last_used_before)
2436 .is_ok_and(|age| age > max_age)
2437 });
2438 let oversized = policy
2439 .max_size_bytes()
2440 .is_some_and(|max_size_bytes| logical_size_bytes_before > max_size_bytes);
2441 let cleared = expired || oversized;
2442 if cleared {
2443 clear_shared_incremental_target_contents(canonical)?;
2444 record_cache_entry_use(canonical)?;
2445 }
2446 let logical_size_bytes_after = if cleared {
2447 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2448 operation: "measure shared incremental Cargo target after maintenance",
2449 path: canonical.to_owned(),
2450 source,
2451 })?
2452 } else {
2453 logical_size_bytes_before
2454 };
2455 Ok(SharedIncrementalTargetMaintenance {
2456 target_dir: canonical.to_owned(),
2457 logical_size_bytes_before,
2458 logical_size_bytes_after,
2459 last_used_before,
2460 cleared,
2461 lock_wait,
2462 maintenance: started.elapsed(),
2463 })
2464}
2465
2466fn clear_shared_incremental_target_contents(target_dir: &Path) -> Result<(), WasmBuildError> {
2467 let entries = fs::read_dir(target_dir).map_err(|source| WasmBuildError::Io {
2468 operation: "read shared incremental Cargo target for maintenance",
2469 path: target_dir.to_owned(),
2470 source,
2471 })?;
2472 for entry in entries {
2473 let path = entry
2474 .map_err(|source| WasmBuildError::Io {
2475 operation: "read shared incremental Cargo target entry for maintenance",
2476 path: target_dir.to_owned(),
2477 source,
2478 })?
2479 .path();
2480 let preserved = path
2481 .file_name()
2482 .is_some_and(|name| name == ".ic-testkit" || name == "CACHEDIR.TAG");
2483 if !preserved {
2484 remove_path_if_present(&path).map_err(|source| WasmBuildError::Io {
2485 operation: "clear shared incremental Cargo target entry",
2486 path,
2487 source,
2488 })?;
2489 }
2490 }
2491 Ok(())
2492}
2493
2494pub fn build_wasm_canisters_cached(
2525 spec: &WasmBuildSpec,
2526 mut spawn: impl FnMut(&mut Command) -> io::Result<OwnedChild>,
2527) -> Result<WasmBuildOutcome, WasmBuildError> {
2528 build_wasm_canisters_cached_internal(spec, &mut ProgressReporter::silent(), None, &mut spawn)
2529}
2530
2531pub fn build_wasm_canisters_cached_with_progress<F>(
2540 spec: &WasmBuildSpec,
2541 config: WasmBuildProgressConfig,
2542 mut observer: F,
2543 mut spawn: impl FnMut(&mut Command) -> io::Result<OwnedChild>,
2544) -> Result<WasmBuildOutcome, WasmBuildError>
2545where
2546 F: FnMut(WasmBuildProgressEvent),
2547{
2548 build_wasm_canisters_cached_internal(
2549 spec,
2550 &mut ProgressReporter::new(Some((config, &mut observer)))?,
2551 None,
2552 &mut spawn,
2553 )
2554}
2555
2556#[expect(
2557 clippy::result_large_err,
2558 reason = "Batch entries retain complete failure details inline without per-failure allocation."
2559)]
2560pub(super) fn build_wasm_canisters_cached_in_batch(
2561 spec: &WasmBuildSpec,
2562 index: usize,
2563 resolver: &mut WasmBuildBatchInputResolver<'_, '_>,
2564 observation: Option<(
2565 WasmBuildProgressConfig,
2566 &mut dyn FnMut(WasmBuildProgressEvent),
2567 )>,
2568 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
2569) -> Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)> {
2570 let mut progress = ProgressReporter::new(observation).map_err(|error| {
2571 (
2572 error,
2573 WasmBuildFailureDetails::specification(Duration::ZERO),
2574 )
2575 })?;
2576 let started = Instant::now();
2577 let result = build_wasm_canisters_cached_internal(
2578 spec,
2579 &mut progress,
2580 Some((resolver, index)),
2581 &mut spawn,
2582 );
2583 batch_result(result, &progress, resolver, started)
2584}
2585
2586#[expect(
2587 clippy::result_large_err,
2588 reason = "Batch entries retain complete failure details inline without per-failure allocation."
2589)]
2590fn batch_result(
2591 result: Result<WasmBuildOutcome, WasmBuildError>,
2592 progress: &ProgressReporter<'_>,
2593 resolver: &WasmBuildBatchInputResolver<'_, '_>,
2594 started: Instant,
2595) -> Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)> {
2596 if result
2597 .as_ref()
2598 .is_err_and(WasmBuildError::indicates_input_change)
2599 {
2600 resolver.invalidate_source_lease();
2601 }
2602 let total = started.elapsed();
2603 result.map_err(|error| {
2604 let details = progress.failure_details(&error, total);
2605 (error, details)
2606 })
2607}
2608
2609fn batch_prepared_invalidation(
2610 batch_resolution: Option<&(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2611) -> Option<Arc<RwLock<bool>>> {
2612 batch_resolution.and_then(|(resolver, _)| resolver.prepared_invalidation())
2613}
2614
2615fn build_wasm_canisters_cached_internal(
2616 spec: &WasmBuildSpec,
2617 progress: &mut ProgressReporter<'_>,
2618 mut batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2619 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
2620) -> Result<WasmBuildOutcome, WasmBuildError> {
2621 let total_started = Instant::now();
2622 validate_spec(spec)?;
2623 let prepared_invalidation = batch_prepared_invalidation(batch_resolution.as_ref());
2624 progress.emit(WasmBuildProgressEvent::Started);
2625 if spec.shared_incremental_maintenance_config.is_some() {
2626 let outcome = build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2627 spec,
2628 total_started,
2629 progress,
2630 batch_resolution.take(),
2631 &mut spawn,
2632 )?;
2633 emit_finished_progress(&outcome, progress);
2634 return Ok(outcome);
2635 }
2636 let (cache_lock, first_lock_wait) =
2637 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2638 ensure_cache_directory_tag(&spec.target_dir)?;
2639
2640 let resolved = resolve_initial_inputs(spec, batch_resolution.take(), progress)?;
2641 let isolated_acquisition = WasmAcquisitionContext::isolated(prepared_invalidation);
2642 if let Some(outcome) = try_reuse_wasm_artifacts(
2643 spec,
2644 &resolved,
2645 first_lock_wait,
2646 &isolated_acquisition,
2647 total_started,
2648 progress,
2649 )? {
2650 emit_finished_progress(&outcome, progress);
2651 return Ok(outcome);
2652 }
2653 progress.emit(WasmBuildProgressEvent::CacheMiss {
2654 fingerprint: resolved.fingerprint,
2655 });
2656
2657 let outcome = match &spec.cache_mode {
2658 WasmBuildCacheMode::Isolated => {
2659 let cache_entry = cache_entry_directory(spec, resolved.fingerprint);
2660 build_wasm_cache_miss(
2661 spec,
2662 &resolved,
2663 first_lock_wait,
2664 isolated_acquisition,
2665 &cache_entry,
2666 total_started,
2667 progress,
2668 &mut spawn,
2669 )
2670 }
2671 WasmBuildCacheMode::SharedIncremental { .. } => {
2672 drop(cache_lock);
2673 build_wasm_with_shared_incremental(
2674 spec,
2675 resolved,
2676 first_lock_wait,
2677 isolated_acquisition,
2678 total_started,
2679 progress,
2680 &mut spawn,
2681 )
2682 }
2683 }?;
2684 emit_finished_progress(&outcome, progress);
2685 Ok(outcome)
2686}
2687
2688fn build_wasm_with_shared_incremental(
2689 spec: &WasmBuildSpec,
2690 resolved: ResolvedCargoBuildInputs,
2691 first_lock_wait: Duration,
2692 source: WasmAcquisitionContext,
2693 total_started: Instant,
2694 progress: &mut ProgressReporter<'_>,
2695 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
2696) -> Result<WasmBuildOutcome, WasmBuildError> {
2697 let (shared_lock, shared_lock_wait, shared_target) =
2698 lock_shared_incremental_target_with_progress(spec, progress)?;
2699 let (_cache_lock, second_lock_wait) =
2700 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2701 ensure_cache_directory_tag(&spec.target_dir)?;
2702
2703 let current = if source.prepared_invalidation.is_some() {
2704 resolved
2705 } else {
2706 let mut current = resolve_initial_inputs(spec, None, progress)?;
2707 current.timings.include(resolved.timings);
2708 current
2709 };
2710 let lock_wait = first_lock_wait.saturating_add(second_lock_wait);
2711 let shared_incremental =
2712 WasmAcquisitionContext::shared(shared_lock_wait, None, source.prepared_invalidation);
2713 if let Some(outcome) = try_reuse_wasm_artifacts(
2714 spec,
2715 ¤t,
2716 lock_wait,
2717 &shared_incremental,
2718 total_started,
2719 progress,
2720 )? {
2721 return Ok(outcome);
2722 }
2723
2724 let outcome = build_wasm_cache_miss(
2725 spec,
2726 ¤t,
2727 lock_wait,
2728 shared_incremental,
2729 &shared_target,
2730 total_started,
2731 progress,
2732 &mut spawn,
2733 );
2734 drop(shared_lock);
2735 outcome
2736}
2737
2738fn build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2739 spec: &WasmBuildSpec,
2740 total_started: Instant,
2741 progress: &mut ProgressReporter<'_>,
2742 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2743 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
2744) -> Result<WasmBuildOutcome, WasmBuildError> {
2745 let prepared_invalidation = batch_prepared_invalidation(batch_resolution.as_ref());
2746 let (_shared_lock, shared_lock_wait, shared_target) =
2747 lock_shared_incremental_target_with_progress(spec, progress)?;
2748 let (_cache_lock, lock_wait) = lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2749 ensure_cache_directory_tag(&spec.target_dir)?;
2750
2751 let resolved = resolve_initial_inputs(spec, batch_resolution, progress)?;
2754 let shared_maintenance = perform_configured_shared_incremental_target_maintenance(
2755 spec,
2756 &shared_target,
2757 shared_lock_wait,
2758 progress,
2759 )?;
2760 let shared_incremental = WasmAcquisitionContext::shared(
2761 shared_lock_wait,
2762 Some(shared_maintenance),
2763 prepared_invalidation,
2764 );
2765 if let Some(outcome) = try_reuse_wasm_artifacts(
2766 spec,
2767 &resolved,
2768 lock_wait,
2769 &shared_incremental,
2770 total_started,
2771 progress,
2772 )? {
2773 return Ok(outcome);
2774 }
2775 progress.emit(WasmBuildProgressEvent::CacheMiss {
2776 fingerprint: resolved.fingerprint,
2777 });
2778 build_wasm_cache_miss(
2779 spec,
2780 &resolved,
2781 lock_wait,
2782 shared_incremental,
2783 &shared_target,
2784 total_started,
2785 progress,
2786 &mut spawn,
2787 )
2788}
2789
2790fn perform_configured_shared_incremental_target_maintenance(
2791 spec: &WasmBuildSpec,
2792 shared_target: &Path,
2793 lock_wait: Duration,
2794 progress: &mut ProgressReporter<'_>,
2795) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2796 let config = spec
2797 .shared_incremental_maintenance_config
2798 .expect("configured shared-target maintenance must have settings");
2799 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceStarted {
2800 target_dir: shared_target.to_owned(),
2801 });
2802 let result = progress.run_phase(WasmBuildPhase::SharedTargetMaintenance, || {
2803 let schedule = schedule_shared_incremental_target_maintenance(
2804 shared_target,
2805 config.policy,
2806 config.minimum_interval,
2807 lock_wait,
2808 )?;
2809 match schedule {
2810 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => Ok(outcome),
2811 SharedIncrementalTargetMaintenanceSchedule::Due(due) => {
2812 perform_due_shared_incremental_target_maintenance(
2813 shared_target,
2814 config.policy,
2815 lock_wait,
2816 due,
2817 )
2818 }
2819 }
2820 });
2821 let outcome = integrated_shared_maintenance_result(config, shared_target, lock_wait, result)?;
2822 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceFinished {
2823 outcome: outcome.clone(),
2824 });
2825 Ok(outcome)
2826}
2827
2828fn integrated_shared_maintenance_result(
2829 config: SharedIncrementalTargetMaintenanceConfig,
2830 shared_target: &Path,
2831 lock_wait: Duration,
2832 result: Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError>,
2833) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2834 match result {
2835 Ok(outcome) => Ok(outcome),
2836 Err(error)
2837 if config.failure_mode == SharedIncrementalTargetMaintenanceFailureMode::BestEffort =>
2838 {
2839 Ok(SharedIncrementalTargetMaintenanceOutcome::Failed {
2840 target_dir: shared_target.to_owned(),
2841 lock_wait,
2842 message: error.to_string(),
2843 })
2844 }
2845 Err(error) => Err(error),
2846 }
2847}
2848
2849fn resolve_initial_inputs(
2850 spec: &WasmBuildSpec,
2851 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2852 progress: &mut ProgressReporter<'_>,
2853) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2854 let resolved = if let Some((resolver, index)) = batch_resolution {
2855 resolver.resolve(index, progress)?
2856 } else {
2857 build_fingerprint_with_progress(spec, progress)?
2858 };
2859 resolved.wasm_outputs().inspect_err(|_| {
2860 progress.begin_phase(WasmBuildPhase::InputDiscovery);
2861 })?;
2862 progress.emit(WasmBuildProgressEvent::InputsResolved {
2863 fingerprint: resolved.fingerprint,
2864 input_digest: resolved.input_digest,
2865 elapsed: resolved.timings.total,
2866 });
2867 Ok(resolved)
2868}
2869
2870fn emit_finished_progress(outcome: &WasmBuildOutcome, progress: &mut ProgressReporter<'_>) {
2871 let state = if outcome.is_reused() {
2872 progress.emit(WasmBuildProgressEvent::CacheHit {
2873 fingerprint: outcome.record().fingerprint,
2874 });
2875 WasmBuildProgressOutcome::Reused
2876 } else {
2877 WasmBuildProgressOutcome::Built
2878 };
2879 progress.emit(WasmBuildProgressEvent::Finished {
2880 outcome: state,
2881 fingerprint: outcome.record().fingerprint,
2882 elapsed: outcome.record().timings.total,
2883 });
2884}
2885
2886#[derive(Clone, Debug, Default)]
2887struct WasmAcquisitionContext {
2888 lock_wait: Option<Duration>,
2889 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
2890 prepared_invalidation: Option<Arc<RwLock<bool>>>,
2891}
2892
2893impl WasmAcquisitionContext {
2894 fn isolated(prepared_invalidation: Option<Arc<RwLock<bool>>>) -> Self {
2895 Self {
2896 prepared_invalidation,
2897 ..Self::default()
2898 }
2899 }
2900
2901 const fn shared(
2902 lock_wait: Duration,
2903 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
2904 prepared_invalidation: Option<Arc<RwLock<bool>>>,
2905 ) -> Self {
2906 Self {
2907 lock_wait: Some(lock_wait),
2908 maintenance,
2909 prepared_invalidation,
2910 }
2911 }
2912
2913 fn lock_prepared_publication(
2914 &self,
2915 ) -> Result<Option<std::sync::RwLockReadGuard<'_, bool>>, WasmBuildError> {
2916 let guard = self.prepared_invalidation.as_deref().map(|invalidation| {
2917 invalidation
2918 .read()
2919 .unwrap_or_else(std::sync::PoisonError::into_inner)
2920 });
2921 if guard.as_deref().is_some_and(|invalidated| *invalidated) {
2922 return Err(WasmBuildError::PreparedInputSnapshotInvalidated);
2923 }
2924 Ok(guard)
2925 }
2926}
2927
2928fn try_reuse_wasm_artifacts(
2929 spec: &WasmBuildSpec,
2930 resolved: &ResolvedCargoBuildInputs,
2931 lock_wait: Duration,
2932 shared_incremental: &WasmAcquisitionContext,
2933 total_started: Instant,
2934 progress: &mut ProgressReporter<'_>,
2935) -> Result<Option<WasmBuildOutcome>, WasmBuildError> {
2936 let targets = &resolved.wasm_outputs()?.targets;
2937 let _publication_guard = shared_incremental.lock_prepared_publication()?;
2938 let fingerprint = resolved.fingerprint;
2939 let cache_entry = cache_entry_directory(spec, fingerprint);
2940 let paths = artifact_publication_paths(spec, &cache_entry, targets);
2941 let Some(artifact_info) = progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
2942 validated_artifact_set(paths, fingerprint)
2943 }) else {
2944 return Ok(None);
2945 };
2946 progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
2947 materialize_artifacts(&artifact_info, fingerprint, true)?;
2948 record_cache_entry_use(&cache_entry)
2949 })?;
2950 let input_resolution = validate_reused_inputs(spec, resolved, shared_incremental, progress)?;
2951 Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
2952 spec,
2953 BuildRecordInput {
2954 resolved,
2955 lock_wait,
2956 shared_incremental: shared_incremental.clone(),
2957 input_resolution,
2958 cargo_build: None,
2959 active_entry: &cache_entry,
2960 targets,
2961 },
2962 total_started,
2963 progress,
2964 )?)))
2965}
2966
2967fn validate_reused_inputs(
2968 spec: &WasmBuildSpec,
2969 resolved: &ResolvedCargoBuildInputs,
2970 context: &WasmAcquisitionContext,
2971 progress: &mut ProgressReporter<'_>,
2972) -> Result<WasmInputResolutionTimings, WasmBuildError> {
2973 let mut timings = resolved.timings;
2974 if context.prepared_invalidation.is_none() {
2975 let verified = verify_resolved_inputs(spec, resolved, progress)?;
2976 timings.include(verified.timings);
2977 }
2978 Ok(timings)
2979}
2980
2981fn verify_resolved_inputs(
2982 spec: &WasmBuildSpec,
2983 resolved: &ResolvedCargoBuildInputs,
2984 progress: &mut ProgressReporter<'_>,
2985) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2986 let verified = resolve_initial_inputs(spec, None, progress)?;
2987 for (before, after) in [
2988 (resolved.validation_digest, verified.validation_digest),
2989 (resolved.fingerprint, verified.fingerprint),
2990 ] {
2991 if before != after {
2992 return Err(WasmBuildError::InputsChangedDuringAcquisition { before, after });
2993 }
2994 }
2995 Ok(verified)
2996}
2997
2998#[expect(
2999 clippy::too_many_arguments,
3000 reason = "keep the caller's spawner separate from recipe, acquisition and progress state"
3001)]
3002fn build_wasm_cache_miss(
3003 spec: &WasmBuildSpec,
3004 resolved: &ResolvedCargoBuildInputs,
3005 lock_wait: Duration,
3006 shared_incremental: WasmAcquisitionContext,
3007 cargo_target_dir: &Path,
3008 total_started: Instant,
3009 progress: &mut ProgressReporter<'_>,
3010 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
3011) -> Result<WasmBuildOutcome, WasmBuildError> {
3012 let targets = &resolved.wasm_outputs()?.targets;
3013 let fingerprint = resolved.fingerprint;
3014 let mut input_resolution = resolved.timings;
3015 let cache_entry = cache_entry_directory(spec, fingerprint);
3016 let preparation_started = Instant::now();
3017 progress.begin_phase(WasmBuildPhase::ArtifactPublication);
3018 let preparation_result = prepare_exact_cache_entry(&cache_entry);
3019 progress.record_phase(
3020 WasmBuildPhase::ArtifactPublication,
3021 preparation_started.elapsed(),
3022 );
3023 let incomplete_directory = preparation_result?;
3024 let build_result = (|| {
3025 if matches!(
3026 spec.cache_mode,
3027 WasmBuildCacheMode::SharedIncremental { .. }
3028 ) {
3029 record_cache_entry_use(cargo_target_dir)?;
3030 }
3031 let build_started = Instant::now();
3032 progress.begin_phase(WasmBuildPhase::CargoBuild);
3033 let cargo_result = run_cargo_build(spec, cargo_target_dir, targets, progress, &mut spawn);
3034 let cargo_build = build_started.elapsed();
3035 progress.record_phase(WasmBuildPhase::CargoBuild, cargo_build);
3036 let built_artifacts = cargo_result?;
3037 let validation_started = Instant::now();
3038 progress.begin_phase(WasmBuildPhase::ArtifactPublication);
3039 let missing = missing_artifacts(&built_artifacts);
3040 progress.record_phase(
3041 WasmBuildPhase::ArtifactPublication,
3042 validation_started.elapsed(),
3043 );
3044 if !missing.is_empty() {
3045 return Err(WasmBuildError::MissingArtifacts { paths: missing });
3046 }
3047
3048 let verified = verify_resolved_inputs(spec, resolved, progress)?;
3049 input_resolution.include(verified.timings);
3050
3051 let publication_guard = shared_incremental.lock_prepared_publication()?;
3056
3057 let paths = artifact_publication_paths(spec, &cache_entry, targets);
3058 progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
3059 copy_wasm_artifacts(&built_artifacts, &paths)?;
3060 let artifacts = publish_artifact_stamps(paths, fingerprint)?;
3061 materialize_artifacts(&artifacts, fingerprint, false)?;
3062 record_cache_entry_use(&cache_entry)
3063 })?;
3064 drop(publication_guard);
3065
3066 Ok(WasmBuildOutcome::Built(complete_build_record(
3067 spec,
3068 BuildRecordInput {
3069 resolved,
3070 lock_wait,
3071 shared_incremental,
3072 input_resolution,
3073 cargo_build: Some(cargo_build),
3074 active_entry: &cache_entry,
3075 targets,
3076 },
3077 total_started,
3078 progress,
3079 )?))
3080 })();
3081 finish_fingerprint_build(build_result, incomplete_directory, progress)
3082}
3083
3084pub fn prune_wasm_build_cache(
3096 target_dir: &Path,
3097 policy: CacheRetentionLimits,
3098) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3099 let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
3100 ensure_cache_directory_tag(target_dir)?;
3101
3102 prune_wasm_build_cache_locked(target_dir, policy, None)
3103}
3104
3105struct BuildRecordInput<'a> {
3106 resolved: &'a ResolvedCargoBuildInputs,
3107 lock_wait: Duration,
3108 shared_incremental: WasmAcquisitionContext,
3109 input_resolution: WasmInputResolutionTimings,
3110 cargo_build: Option<Duration>,
3111 active_entry: &'a Path,
3112 targets: &'a [WasmArtifactTarget],
3113}
3114
3115fn complete_build_record(
3116 spec: &WasmBuildSpec,
3117 input: BuildRecordInput<'_>,
3118 total_started: Instant,
3119 progress: &mut ProgressReporter<'_>,
3120) -> Result<WasmBuildRecord, WasmBuildError> {
3121 let retention = progress.run_phase(WasmBuildPhase::ArtifactPublication, || {
3122 RetainedCacheEntry::acquire(input.active_entry).map_err(wasm_cache_fs_error)
3123 })?;
3124 let (maintenance, cache_maintenance) =
3125 spec.pruning.map_or((None, None), |(policy, interval)| {
3126 progress.run_phase(WasmBuildPhase::ExactCacheMaintenance, || {
3127 let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
3128 let identity = policy.maintenance_identity();
3129 perform_scheduled_cache_maintenance(&cache_root, interval, &identity, || {
3130 prune_wasm_build_cache_locked(
3131 &spec.target_dir,
3132 policy,
3133 Some(input.active_entry),
3134 )
3135 .map_err(|error| error.to_string())
3136 })
3137 })
3138 });
3139 Ok(WasmBuildRecord {
3140 facts: input.resolved.facts.clone(),
3141 fingerprint: input.resolved.fingerprint,
3142 input_digest: input.resolved.input_digest,
3143 artifacts: expected_artifacts(spec, input.active_entry, input.targets).into(),
3144 retention,
3145 timings: WasmBuildTimings {
3146 lock_wait: input.lock_wait,
3147 shared_incremental_lock_wait: input.shared_incremental.lock_wait,
3148 input_resolution: input.input_resolution,
3149 cargo_build: input.cargo_build,
3150 cache_maintenance,
3151 total: total_started.elapsed(),
3152 },
3153 maintenance,
3154 shared_incremental_maintenance: input.shared_incremental.maintenance,
3155 })
3156}
3157
3158fn prune_wasm_build_cache_locked(
3159 target_dir: &Path,
3160 policy: CacheRetentionLimits,
3161 protected_entry: Option<&Path>,
3162) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3163 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
3164 prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
3165 .map_err(wasm_cache_fs_error)
3166}
3167
3168fn finish_fingerprint_build<T>(
3169 result: Result<T, WasmBuildError>,
3170 incomplete_directory: OwnedDirectory,
3171 progress: &mut ProgressReporter<'_>,
3172) -> Result<T, WasmBuildError> {
3173 match result {
3174 Ok(outcome) => {
3175 incomplete_directory.preserve();
3176 Ok(outcome)
3177 }
3178 Err(build_error) => Err(cleanup_failed_fingerprint_build(
3179 build_error,
3180 incomplete_directory,
3181 progress,
3182 )),
3183 }
3184}
3185
3186fn cleanup_failed_fingerprint_build(
3187 build_error: WasmBuildError,
3188 incomplete_directory: OwnedDirectory,
3189 progress: &mut ProgressReporter<'_>,
3190) -> WasmBuildError {
3191 let path = incomplete_directory.path().to_owned();
3192 let primary_phase = progress.failure_phase;
3193 let cleanup_started = Instant::now();
3194 let cleanup = incomplete_directory.cleanup();
3195 progress.record_phase(WasmBuildPhase::Cleanup, cleanup_started.elapsed());
3196 match cleanup {
3197 Ok(()) => {
3198 progress.failure_phase = primary_phase;
3199 build_error
3200 }
3201 Err(source) => WasmBuildError::FailedBuildCleanup {
3202 build_error: Box::new(build_error),
3203 path,
3204 source,
3205 },
3206 }
3207}
3208
3209fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
3210 create_dir_all(target_dir, "create Cargo target directory")?;
3211 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3212 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)
3213}
3214
3215fn lock_wasm_build_cache_with_progress(
3216 target_dir: &Path,
3217 progress: &mut ProgressReporter<'_>,
3218) -> Result<(File, Duration), WasmBuildError> {
3219 progress.begin_phase(WasmBuildPhase::ExactCacheCoordination);
3220 create_dir_all(target_dir, "create Cargo target directory")?;
3221 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3222 lock_cache_file_with_progress(&lock_path, WasmBuildPhase::ExactCacheCoordination, progress)
3223}
3224
3225fn lock_shared_incremental_target(
3226 spec: &WasmBuildSpec,
3227) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3228 lock_shared_incremental_target_internal(spec, None)
3229}
3230
3231fn lock_shared_incremental_target_with_progress(
3232 spec: &WasmBuildSpec,
3233 progress: &mut ProgressReporter<'_>,
3234) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3235 let target_dir = shared_incremental_target(spec)
3236 .expect("validated shared acquisition must have a shared Cargo target");
3237 progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted { target_dir });
3238 let (lock, wait, canonical) = lock_shared_incremental_target_internal(spec, Some(progress))?;
3239 progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
3240 target_dir: canonical.clone(),
3241 wait,
3242 });
3243 Ok((lock, wait, canonical))
3244}
3245
3246fn lock_shared_incremental_target_internal(
3247 spec: &WasmBuildSpec,
3248 mut progress: Option<&mut ProgressReporter<'_>>,
3249) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3250 if let Some(progress) = progress.as_deref_mut() {
3251 progress.begin_phase(WasmBuildPhase::SharedTargetCoordination);
3252 }
3253 let target_dir =
3254 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
3255 message: "shared incremental target is not configured".to_owned(),
3256 })?;
3257 create_dir_all(
3258 &target_dir,
3259 "create shared incremental Cargo target directory",
3260 )?;
3261 ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
3262 let canonical = target_dir
3263 .canonicalize()
3264 .map_err(|source| WasmBuildError::Io {
3265 operation: "resolve shared incremental Cargo target directory",
3266 path: target_dir.clone(),
3267 source,
3268 })?;
3269 let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
3270 let (lock, wait) = if let Some(progress) = progress {
3271 lock_cache_file_with_progress(
3272 &lock_path,
3273 WasmBuildPhase::SharedTargetCoordination,
3274 progress,
3275 )?
3276 } else {
3277 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)?
3278 };
3279 Ok((lock, wait, canonical))
3280}
3281
3282fn lock_cache_file_with_progress(
3283 lock_path: &Path,
3284 phase: WasmBuildPhase,
3285 progress: &mut ProgressReporter<'_>,
3286) -> Result<(File, Duration), WasmBuildError> {
3287 let started = Instant::now();
3288 progress.begin_phase(phase);
3289 let result = if !progress.is_observed() || progress.config.heartbeat_interval.is_none() {
3290 lock_cache_file(lock_path).map_err(wasm_cache_fs_error)
3291 } else {
3292 let heartbeat_interval = progress
3293 .config
3294 .heartbeat_interval
3295 .expect("observed cache lock must have a heartbeat interval");
3296 lock_cache_file_with_wait_observer(lock_path, heartbeat_interval, |elapsed| {
3297 progress.emit_heartbeat_if_due(phase, elapsed);
3298 })
3299 .map_err(wasm_cache_fs_error)
3300 };
3301 progress.record_phase(phase, started.elapsed());
3302 result
3303}
3304
3305fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
3306 ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
3307}
3308
3309fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
3310 record_entry_use(path).map_err(wasm_cache_fs_error)
3311}
3312
3313fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
3314 WasmBuildError::Io {
3315 operation: error.operation,
3316 path: error.path,
3317 source: error.source,
3318 }
3319}
3320
3321fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3322 for (key, value) in &spec.environment {
3323 let key = key.as_encoded_bytes();
3324 if key.is_empty()
3325 || key.iter().any(|byte| matches!(byte, b'=' | 0))
3326 || value.as_encoded_bytes().contains(&0)
3327 {
3328 return Err(WasmBuildError::InvalidSpec {
3329 message: "invalid child environment name or value".to_owned(),
3330 });
3331 }
3332 }
3333 if spec.cargo_message_bytes == 0 {
3334 return Err(WasmBuildError::InvalidSpec {
3335 message: "Cargo message allowance must be nonzero".to_owned(),
3336 });
3337 }
3338 if spec.packages.is_empty() {
3339 return Err(WasmBuildError::InvalidSpec {
3340 message: "at least one Cargo package is required".to_owned(),
3341 });
3342 }
3343 if spec.target.is_empty() {
3344 return Err(WasmBuildError::InvalidSpec {
3345 message: "Cargo compilation target must not be empty".to_owned(),
3346 });
3347 }
3348 if spec
3349 .environment
3350 .contains_key(OsStr::new("CARGO_TARGET_DIR"))
3351 {
3352 return Err(WasmBuildError::InvalidSpec {
3353 message: "Cargo target directories are owned by the build specification; use target_dir or with_shared_incremental_target instead of command overrides".to_owned(),
3354 });
3355 }
3356 validate_cargo_target_selection(spec)?;
3357 spec.cargo_selection
3358 .profile_target_dir()
3359 .map_err(|message| WasmBuildError::InvalidSpec {
3360 message: message.to_owned(),
3361 })?;
3362 if matches!(
3363 &spec.cache_mode,
3364 WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
3365 ) {
3366 return Err(WasmBuildError::InvalidSpec {
3367 message: "shared incremental Cargo target directory must not be empty".to_owned(),
3368 });
3369 }
3370 if spec.shared_incremental_maintenance_config.is_some()
3371 && !matches!(
3372 spec.cache_mode,
3373 WasmBuildCacheMode::SharedIncremental { .. }
3374 )
3375 {
3376 return Err(WasmBuildError::InvalidSpec {
3377 message:
3378 "scheduled shared-target maintenance requires a shared incremental Cargo target"
3379 .to_owned(),
3380 });
3381 }
3382 Ok(())
3383}
3384
3385fn validate_cargo_target_selection(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3386 if let Some(example) = &spec.example {
3387 let mut components = Path::new(example).components();
3388 if spec.packages.len() != 1
3389 || !matches!(
3390 (components.next(), components.next()),
3391 (Some(Component::Normal(_)), None)
3392 )
3393 {
3394 return Err(WasmBuildError::InvalidSpec {
3395 message: "a named Cargo example requires one package, a normal target name"
3396 .to_owned(),
3397 });
3398 }
3399 }
3400 if spec.packages.iter().any(|package| {
3401 let mut components = Path::new(package).components();
3402 !matches!(
3403 (components.next(), components.next()),
3404 (Some(Component::Normal(_)), None)
3405 )
3406 }) {
3407 return Err(WasmBuildError::InvalidSpec {
3408 message: "Cargo package names must be normal path components".to_owned(),
3409 });
3410 }
3411 Ok(())
3412}
3413
3414fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3415 build_fingerprint_with_progress(spec, &mut ProgressReporter::silent())
3416}
3417
3418fn build_fingerprint_with_progress(
3419 spec: &WasmBuildSpec,
3420 progress: &mut ProgressReporter<'_>,
3421) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3422 build_fingerprint_with_policy(spec, progress, &mut None)
3423}
3424
3425fn build_fingerprint_with_policy(
3426 spec: &WasmBuildSpec,
3427 progress: &mut ProgressReporter<'_>,
3428 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3429) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3430 let total_started = Instant::now();
3431 let (cargo_identity, rustc_identity, tool_identity) =
3432 resolve_tool_identity_with_policy(spec, progress, policy)?;
3433
3434 let metadata_started = Instant::now();
3435 let metadata = progress.run_phase(WasmBuildPhase::CargoMetadata, || {
3436 cargo_metadata_with_policy(spec, policy)
3437 })?;
3438 let cargo_metadata = metadata_started.elapsed();
3439
3440 let discovery_started = Instant::now();
3441 let (inputs, mut exclusions) = progress.run_phase(WasmBuildPhase::InputDiscovery, || {
3442 let parsed =
3443 ParsedCargoMetadata::parse(&metadata).map_err(|message| invalid_metadata(&message))?;
3444 resolve_local_inputs(spec, &parsed)
3445 })?;
3446 let input_discovery = discovery_started.elapsed();
3447
3448 let hashing_started = Instant::now();
3449 let (input_digest, validation_digest) =
3450 progress.run_phase(WasmBuildPhase::ContentHashing, || {
3451 let mut cache = LabeledPathDigestCache::default();
3452 digest_resolved_local_inputs(
3453 &inputs,
3454 &mut exclusions,
3455 &mut cache,
3456 &spec.workspace_root,
3457 "hash Wasm build inputs",
3458 "hash semantic Wasm build inputs",
3459 )
3460 })?;
3461 let content_hashing = hashing_started.elapsed();
3462
3463 Ok(ResolvedCargoBuildInputs::assemble(
3464 spec,
3465 inputs,
3466 exclusions,
3467 input_digest,
3468 validation_digest,
3469 spec.cargo_invocation()
3470 .facts(cargo_identity, rustc_identity),
3471 || WasmInputResolutionTimings {
3472 tool_identity,
3473 cargo_metadata,
3474 input_discovery,
3475 content_hashing,
3476 total: total_started.elapsed(),
3477 },
3478 ))
3479}
3480
3481fn cargo_build_inputs(inputs: Vec<(PathBuf, PathBuf)>) -> Arc<[CargoBuildInput]> {
3482 inputs
3483 .into_iter()
3484 .map(|(label, path)| CargoBuildInput { label, path })
3485 .collect()
3486}
3487
3488fn finish_build_fingerprint(
3489 spec: &WasmBuildSpec,
3490 facts: &CargoBuildFacts,
3491 input_digest: InputDigest,
3492) -> InputDigest {
3493 let mut hasher = InputHasher::new(BUILD_FINGERPRINT_DOMAIN);
3494 for package in &spec.packages {
3495 hasher.field("package", package.as_bytes());
3496 }
3497 hasher.field("target", facts.target.as_bytes());
3498 if let Some(example) = &spec.example {
3499 hasher.field("cargo-target-selection", b"example");
3500 hasher.field("cargo-example", example.as_bytes());
3501 } else {
3502 hasher.field("cargo-target-selection", b"lib");
3503 }
3504 hasher.field(
3505 "profile-target-dir",
3506 facts
3507 .selection
3508 .profile_target_dir()
3509 .expect("Cargo profile was validated before fingerprinting")
3510 .as_bytes(),
3511 );
3512 for argument in &facts.selection.build_arguments() {
3513 hasher.field("cargo-argument", &os_bytes(argument));
3514 }
3515 for (key, value) in &facts.environment {
3516 hasher.field("environment-key", &os_bytes(key));
3517 hasher.field("environment-value", &os_bytes(value));
3518 }
3519 hasher.field("cargo-identity", &facts.cargo_identity);
3520 hasher.field("rustc-identity", &facts.rustc_identity);
3521 hasher.field("source-input-digest", input_digest.as_bytes());
3522 hasher.finish()
3523}
3524
3525#[cfg(test)]
3526fn command_identity(
3527 spec: &WasmBuildSpec,
3528 phase: WasmBuildPhase,
3529 program: &OsStr,
3530 arguments: &[&str],
3531) -> Result<Vec<u8>, WasmBuildError> {
3532 command_identity_with_policy(spec, phase, program, arguments, &mut None)
3533}
3534
3535#[cfg(test)]
3536fn command_identity_with_policy(
3537 spec: &WasmBuildSpec,
3538 phase: WasmBuildPhase,
3539 program: &OsStr,
3540 arguments: &[&str],
3541 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3542) -> Result<Vec<u8>, WasmBuildError> {
3543 crate::cargo::execution::command_identity(
3544 program,
3545 &spec.workspace_root,
3546 &spec.environment,
3547 arguments,
3548 |command| {
3549 let output =
3550 capture_probe_output_with_policy(command, phase, TOOL_IDENTITY_BYTES, policy)?;
3551 ensure_command_success(phase, output)
3552 },
3553 )
3554}
3555
3556fn cargo_metadata(spec: &WasmBuildSpec) -> Result<schema::Metadata, WasmBuildError> {
3557 cargo_metadata_with_policy(spec, &mut None)
3558}
3559
3560fn cargo_metadata_with_policy(
3561 spec: &WasmBuildSpec,
3562 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3563) -> Result<schema::Metadata, WasmBuildError> {
3564 let mut command = Command::new(&spec.cargo_program);
3565 command
3566 .current_dir(&spec.workspace_root)
3567 .args(["metadata", "--format-version", "1"]);
3568 for argument in spec.cargo_selection.metadata_arguments() {
3569 command.arg(argument);
3570 }
3571 apply_command_environment(&mut command, spec);
3572 let output = capture_probe_output_with_policy(
3573 &mut command,
3574 WasmBuildPhase::CargoMetadata,
3575 CARGO_METADATA_BYTES,
3576 policy,
3577 )?;
3578 let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
3579 crate::cargo::observation::decode_metadata(&output.stdout).map_err(|error| {
3580 WasmBuildError::InvalidMetadata {
3581 message: format!("Cargo metadata did not match its schema: {error}"),
3582 }
3583 })
3584}
3585
3586#[cfg(test)]
3587fn capture_probe_output(
3588 command: &mut Command,
3589 phase: WasmBuildPhase,
3590 stdout_bytes: usize,
3591) -> Result<CompletedExecution, WasmBuildError> {
3592 capture_probe_output_with_policy(command, phase, stdout_bytes, &mut None)
3593}
3594
3595fn capture_probe_output_with_policy(
3596 command: &mut Command,
3597 phase: WasmBuildPhase,
3598 stdout_bytes: usize,
3599 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3600) -> Result<CompletedExecution, WasmBuildError> {
3601 let limits = OutputLimits {
3602 stdout: OutputLimit::Terminate(stdout_bytes),
3603 stderr: OutputLimit::Terminate(TOOL_IDENTITY_BYTES),
3604 timeout: None,
3605 };
3606 let result = if let Some(policy) = policy {
3607 capture_group_command_with_cancellation(
3608 command,
3609 limits,
3610 policy.cleanup,
3611 policy.deadline,
3612 &mut *policy.cancelled,
3613 )
3614 } else {
3615 capture_command(command, limits)
3616 }
3617 .and_then(CompletedExecution::require_complete);
3618 let evidence = match result {
3619 Ok(evidence) => evidence,
3620 Err(ToolError::Execution(error))
3621 if matches!(
3622 error.failure,
3623 ExecutionFailure::Io {
3624 operation: ExecutionOperation::Spawn,
3625 ..
3626 }
3627 ) && error.cleanup.is_none() =>
3628 {
3629 let ExecutionFailure::Io { source, .. } = error.failure else {
3630 unreachable!()
3631 };
3632 return Err(WasmBuildError::CommandSpawn {
3633 phase,
3634 program: command.get_program().to_owned(),
3635 source,
3636 });
3637 }
3638 Err(error) => {
3639 return Err(WasmBuildError::Io {
3640 operation: match phase {
3641 WasmBuildPhase::CargoIdentity => "capture complete Cargo identity probe output",
3642 WasmBuildPhase::RustcIdentity => "capture complete rustc identity probe output",
3643 _ => "capture complete Cargo metadata probe output",
3644 },
3645 path: PathBuf::from(command.get_program()),
3646 source: io::Error::other(error),
3647 });
3648 }
3649 };
3650 Ok(evidence)
3651}
3652
3653struct ParsedCargoMetadata<'a> {
3656 packages: HashMap<&'a str, &'a schema::Package>,
3657 workspace_packages: HashMap<&'a str, Option<&'a str>>,
3658 nodes: HashMap<&'a str, &'a schema::Node>,
3659 workspace_root: &'a Path,
3660}
3661
3662impl<'a> ParsedCargoMetadata<'a> {
3663 fn parse(metadata: &'a schema::Metadata) -> Result<Self, String> {
3664 let packages: HashMap<_, _> = metadata
3665 .packages
3666 .iter()
3667 .map(|package| (package.id.repr.as_str(), package))
3668 .collect();
3669 let workspace_members: HashSet<_> = metadata
3670 .workspace_members
3671 .iter()
3672 .map(|id| id.repr.as_str())
3673 .collect();
3674 let mut workspace_packages = HashMap::new();
3675 for package in packages
3676 .values()
3677 .filter(|package| workspace_members.contains(package.id.repr.as_str()))
3678 {
3679 workspace_packages
3680 .entry(package.name.as_str())
3681 .and_modify(|id| *id = None)
3682 .or_insert(Some(package.id.repr.as_str()));
3683 }
3684 let resolve = metadata
3685 .resolve
3686 .as_ref()
3687 .ok_or_else(|| "Cargo metadata has no resolved dependency nodes".to_owned())?;
3688 let nodes = resolve
3689 .nodes
3690 .iter()
3691 .map(|node| (node.id.repr.as_str(), node))
3692 .collect();
3693 Ok(Self {
3694 packages,
3695 workspace_packages,
3696 nodes,
3697 workspace_root: metadata.workspace_root.as_std_path(),
3698 })
3699 }
3700}
3701
3702struct LockedPackageIdentity {
3703 name: String,
3704 version: String,
3705 source: String,
3706 checksum: Option<String>,
3707}
3708
3709type LockedPackageIndex<'a> = HashMap<(&'a str, &'a str, &'a str), &'a LockedPackageIdentity>;
3710
3711fn index_locked_packages(packages: &[LockedPackageIdentity]) -> LockedPackageIndex<'_> {
3712 let mut index = HashMap::with_capacity(packages.len());
3713 for package in packages {
3714 index
3716 .entry((
3717 package.name.as_str(),
3718 package.version.as_str(),
3719 package.source.as_str(),
3720 ))
3721 .or_insert(package);
3722 }
3723 index
3724}
3725
3726fn resolve_local_inputs(
3727 spec: &WasmBuildSpec,
3728 metadata: &ParsedCargoMetadata<'_>,
3729) -> Result<(ResolvedLocalInputs, Vec<PathBuf>), WasmBuildError> {
3730 let mut selected_ids = selected_package_ids(spec, metadata)?;
3731 let mut artifact_names = BTreeSet::new();
3734 let wasm_targets = selected_ids
3735 .iter()
3736 .map(|id| {
3737 let package = metadata.packages[id];
3738 let mut matching = package.targets.iter().filter(|target| {
3739 target.crate_types.contains(&schema::CrateType::CDyLib)
3740 && spec.example.as_ref().map_or_else(
3741 || target.is_cdylib(),
3742 |example| target.name == *example && target.is_example(),
3743 )
3744 });
3745 let Some(target) = matching.next() else {
3746 return Err(format!(
3747 "Cargo package `{}` must declare the selected cdylib target",
3748 package.name
3749 ));
3750 };
3751 let mut components = Path::new(&target.name).components();
3752 if matching.next().is_some()
3753 || !matches!(
3754 (components.next(), components.next()),
3755 (Some(Component::Normal(_)), None)
3756 )
3757 {
3758 return Err(format!(
3759 "Cargo package `{}` has an ambiguous or unsafe cdylib target",
3760 package.name
3761 ));
3762 }
3763 let cache_name = target.name.replace('-', "_");
3764 if !artifact_names.insert(cache_name.clone()) {
3765 return Err(format!(
3766 "selected Cargo packages share the Wasm artifact name `{cache_name}`"
3767 ));
3768 }
3769 Ok(WasmArtifactTarget {
3770 package_id: package.id.repr.clone(),
3771 name: target.name.clone(),
3772 kind: if spec.example.is_some() {
3773 schema::TargetKind::Example
3774 } else {
3775 schema::TargetKind::CDyLib
3776 },
3777 })
3778 })
3779 .collect();
3780 let mut closure = BTreeSet::new();
3781 while let Some(id) = selected_ids.pop_front() {
3782 if !closure.insert(id) {
3783 continue;
3784 }
3785 if let Some(node) = metadata.nodes.get(id) {
3786 selected_ids.extend(
3787 node.deps
3788 .iter()
3789 .map(|dependency| dependency.pkg.repr.as_str()),
3790 );
3791 }
3792 }
3793
3794 let workspace_root = metadata.workspace_root;
3795 let workspace_projection = semantic_workspace_projection(metadata, &closure, workspace_root)?;
3796 let mut validation_inputs = workspace_configuration_inputs(spec, workspace_root)?;
3797 let declared_package_files = append_package_inputs(
3798 &mut validation_inputs,
3799 &metadata.packages,
3800 closure,
3801 workspace_root,
3802 )?;
3803 append_additional_inputs(&mut validation_inputs, spec, workspace_root);
3804 validate_shared_incremental_target_boundary(spec, &validation_inputs)?;
3805 let base_exclusions = source_exclusions(spec, &validation_inputs);
3806 let exclusions = base_exclusions.clone();
3807 Ok((
3808 ResolvedLocalInputs {
3809 validation_inputs,
3810 base_exclusions,
3811 declared_package_files,
3812 workspace_projection,
3813 wasm_targets,
3814 },
3815 exclusions,
3816 ))
3817}
3818
3819fn selected_package_ids<'a>(
3820 spec: &WasmBuildSpec,
3821 metadata: &ParsedCargoMetadata<'a>,
3822) -> Result<VecDeque<&'a str>, WasmBuildError> {
3823 let mut selected_ids = VecDeque::new();
3824 for requested in &spec.packages {
3825 match metadata.workspace_packages.get(requested.as_str()) {
3826 Some(Some(id)) => selected_ids.push_back(*id),
3827 None => {
3828 return Err(WasmBuildError::InvalidSpec {
3829 message: format!("Cargo workspace contains no package named `{requested}`"),
3830 });
3831 }
3832 Some(None) => {
3833 return Err(WasmBuildError::InvalidSpec {
3834 message: format!("Cargo workspace package name `{requested}` is ambiguous"),
3835 });
3836 }
3837 }
3838 }
3839 Ok(selected_ids)
3840}
3841
3842fn semantic_workspace_projection(
3843 metadata: &ParsedCargoMetadata<'_>,
3844 closure: &BTreeSet<&str>,
3845 workspace_root: &Path,
3846) -> Result<Option<InputDigest>, WasmBuildError> {
3847 let locked_packages = locked_package_identities(workspace_root)?;
3850 let locked_index = index_locked_packages(&locked_packages);
3851 let mut identities = HashMap::new();
3852 for &id in closure {
3853 let package = metadata
3854 .packages
3855 .get(id)
3856 .ok_or_else(|| invalid_metadata(&format!("resolved package `{id}` is missing")))?;
3857 let Some(identity) = semantic_package_identity(package, workspace_root, &locked_index)
3858 else {
3859 return Ok(None);
3860 };
3861 identities.insert(id, identity);
3862 }
3863
3864 let mut projected_packages = closure
3865 .iter()
3866 .map(|&id| {
3867 let package = metadata
3868 .packages
3869 .get(id)
3870 .expect("selected package closure was validated above");
3871 let identity = identities[id];
3872 let node = metadata.nodes.get(id).ok_or_else(|| {
3873 invalid_metadata(&format!("resolved package `{id}` has no dependency node"))
3874 })?;
3875 let projection = semantic_package_projection(package, node, &identities)?;
3876 Ok::<_, WasmBuildError>((identity, projection))
3877 })
3878 .collect::<Result<Vec<_>, _>>()?;
3879 projected_packages.sort_by_key(|(identity, _)| *identity);
3880
3881 let root_manifest = workspace_root.join("Cargo.toml");
3882 let root_contents =
3883 fs::read_to_string(&root_manifest).map_err(|source| WasmBuildError::Io {
3884 operation: "read workspace manifest for semantic projection",
3885 path: root_manifest.clone(),
3886 source,
3887 })?;
3888 let root = toml::from_str::<TomlValue>(&root_contents).map_err(|error| {
3889 invalid_metadata(&format!(
3890 "workspace manifest could not be projected as TOML: {error}"
3891 ))
3892 })?;
3893
3894 let mut hasher = InputHasher::new("wasm-semantic-workspace-projection-v1");
3895 for (identity, projection) in projected_packages {
3896 hasher.field("package-identity", identity.as_bytes());
3897 hasher.field("package-projection", projection.as_bytes());
3898 }
3899 hash_toml_setting(&mut hasher, "cargo-features", root.get("cargo-features"));
3900 hash_toml_setting(&mut hasher, "profile", root.get("profile"));
3901 let workspace = root.get("workspace").and_then(TomlValue::as_table);
3902 hash_toml_setting(
3903 &mut hasher,
3904 "workspace-resolver",
3905 workspace.and_then(|table| table.get("resolver")),
3906 );
3907 hash_toml_setting(
3908 &mut hasher,
3909 "workspace-lints",
3910 workspace.and_then(|table| table.get("lints")),
3911 );
3912 Ok(Some(hasher.finish()))
3913}
3914
3915fn locked_package_identities(
3916 workspace_root: &Path,
3917) -> Result<Vec<LockedPackageIdentity>, WasmBuildError> {
3918 use crate::cargo::lockfile::{external_package, optional_string, required_string};
3919 let lockfile = workspace_root.join("Cargo.lock");
3920 let contents = match fs::read_to_string(&lockfile) {
3921 Ok(contents) => contents,
3922 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
3923 Err(source) => {
3924 return Err(WasmBuildError::Io {
3925 operation: "read Cargo lockfile for semantic projection",
3926 path: lockfile,
3927 source,
3928 });
3929 }
3930 };
3931 let lock = toml::from_str::<TomlValue>(&contents).map_err(|error| {
3932 invalid_metadata(&format!(
3933 "Cargo lockfile could not be projected as TOML: {error}"
3934 ))
3935 })?;
3936 let Some(packages) = lock.get("package").and_then(TomlValue::as_array) else {
3937 return Ok(Vec::new());
3938 };
3939 packages
3940 .iter()
3941 .filter_map(|package| {
3942 let table = match external_package(package) {
3943 Ok(Some(table)) => table,
3944 Ok(None) => return None,
3945 Err(message) => {
3946 return Some(Err(invalid_metadata(&format!("Cargo lockfile {message}"))));
3947 }
3948 };
3949 let text = |field| {
3950 required_string(table, field)
3951 .map(str::to_owned)
3952 .map_err(|_| {
3953 invalid_metadata(&format!(
3954 "Cargo lockfile package `{field}` is missing or not a string"
3955 ))
3956 })
3957 };
3958 let source = required_string(table, "source").map(str::to_owned);
3959 Some(
3960 source
3961 .map_err(|_| invalid_metadata("Cargo lockfile package source is not a string"))
3962 .and_then(|source| {
3963 Ok(LockedPackageIdentity {
3964 name: text("name")?,
3965 version: text("version")?,
3966 source,
3967 checksum: optional_string(table, "checksum")
3968 .map_err(|_| {
3969 invalid_metadata(
3970 "Cargo lockfile package `checksum` is not a string",
3971 )
3972 })?
3973 .map(str::to_owned),
3974 })
3975 }),
3976 )
3977 })
3978 .collect()
3979}
3980
3981fn semantic_package_identity(
3982 package: &schema::Package,
3983 workspace_root: &Path,
3984 locked_packages: &LockedPackageIndex<'_>,
3985) -> Option<InputDigest> {
3986 let version = package.version.to_string();
3987 let mut hasher = InputHasher::new("wasm-semantic-package-identity-v1");
3988 hasher.field("name", package.name.as_bytes());
3989 hasher.field("version", version.as_bytes());
3990 if package.source.is_none() {
3991 let manifest = package.manifest_path.strip_prefix(workspace_root).ok()?;
3992 let package_root = package.manifest_path.parent()?;
3993 if package_root == workspace_root {
3994 return None;
3995 }
3996 hasher.field("local-manifest", &os_bytes(manifest.as_os_str()));
3997 } else {
3998 let metadata_source = &package.source.as_ref()?.repr;
3999 let locked = locked_packages.get(&(
4000 package.name.as_str(),
4001 version.as_str(),
4002 metadata_source.as_str(),
4003 ))?;
4004 match locked.source.as_str() {
4005 source if source.starts_with("registry+") && locked.checksum.is_some() => {}
4006 source if source.starts_with("git+") && source.contains('#') => {}
4007 _ => return None,
4008 }
4009 hasher.field("external-package-id", package.id.repr.as_bytes());
4010 hasher.field("external-source", locked.source.as_bytes());
4011 hasher.field(
4012 "external-checksum",
4013 locked.checksum.as_deref().unwrap_or_default().as_bytes(),
4014 );
4015 }
4016 Some(hasher.finish())
4017}
4018
4019fn semantic_package_projection(
4020 package: &schema::Package,
4021 node: &schema::Node,
4022 identities: &HashMap<&str, InputDigest>,
4023) -> Result<InputDigest, WasmBuildError> {
4024 let mut hasher = InputHasher::new("wasm-semantic-package-projection-v2");
4028 macro_rules! fields {
4031 ($($field:ident),* $(,)?) => {$(
4032 hasher.field("package-field-name", stringify!($field).as_bytes());
4033 hasher.field("package-field-value", &serde_json::to_vec(&package.$field)
4034 .expect("serializing Cargo fields into a Vec cannot fail"));
4035 )*};
4036 }
4037 fields!(
4038 authors,
4039 default_run,
4040 description,
4041 documentation,
4042 edition,
4043 homepage,
4044 license,
4045 license_file,
4046 links,
4047 metadata,
4048 name,
4049 readme,
4050 repository,
4051 rust_version,
4052 version
4053 );
4054
4055 let mut features = node
4056 .features
4057 .iter()
4058 .map(|feature| feature.as_str())
4059 .collect::<Vec<_>>();
4060 features.sort_unstable();
4061 for feature in features {
4062 hasher.field("enabled-feature", feature.as_bytes());
4063 }
4064
4065 let mut dependencies = node
4066 .deps
4067 .iter()
4068 .map(|dependency| {
4069 if dependency.dep_kinds.iter().any(|kind| {
4070 !matches!(
4071 kind.kind,
4072 schema::DependencyKind::Normal
4073 | schema::DependencyKind::Development
4074 | schema::DependencyKind::Build
4075 )
4076 }) {
4077 return Err(invalid_metadata("unsupported Cargo dependency kind"));
4078 }
4079 let name = dependency.name.as_str();
4080 let package_id = dependency.pkg.repr.as_str();
4081 let identity = identities.get(package_id).copied().ok_or_else(|| {
4082 invalid_metadata(&format!(
4083 "dependency `{package_id}` is outside the selected package closure"
4084 ))
4085 })?;
4086 let kinds = serde_json::to_vec(&dependency.dep_kinds)
4087 .expect("serializing Cargo dependency kinds into a Vec cannot fail");
4088 Ok::<_, WasmBuildError>((name, identity, kinds))
4089 })
4090 .collect::<Result<Vec<_>, _>>()?;
4091 dependencies.sort();
4092 for (name, identity, kinds) in dependencies {
4093 hasher.field("dependency-name", name.as_bytes());
4094 hasher.field("dependency-identity", identity.as_bytes());
4095 hasher.field("dependency-kinds", &kinds);
4096 }
4097 Ok(hasher.finish())
4098}
4099
4100fn hash_toml_setting(hasher: &mut InputHasher, label: &str, value: Option<&TomlValue>) {
4101 hasher.field("workspace-setting-name", label.as_bytes());
4102 match value {
4103 Some(value) => hasher.field("workspace-setting-value", value.to_string().as_bytes()),
4104 None => hasher.field("workspace-setting-missing", b""),
4105 }
4106}
4107
4108fn is_broad_workspace_input(label: &Path) -> bool {
4109 label == Path::new("workspace/Cargo.toml") || label == Path::new("workspace/Cargo.lock")
4110}
4111
4112fn digest_resolved_local_inputs(
4113 inputs: &ResolvedLocalInputs,
4114 exclusions: &mut Vec<PathBuf>,
4115 cache: &mut LabeledPathDigestCache,
4116 error_path: &Path,
4117 validation_operation: &'static str,
4118 semantic_operation: &'static str,
4119) -> Result<(InputDigest, InputDigest), WasmBuildError> {
4120 source_inputs::exclude_nested_workspace_targets(
4123 inputs
4124 .validation_inputs
4125 .iter()
4126 .map(|(_, path)| path.as_path()),
4127 &inputs.declared_package_files,
4128 exclusions,
4129 )
4130 .map_err(|source| WasmBuildError::Io {
4131 operation: validation_operation,
4132 path: error_path.to_owned(),
4133 source,
4134 })?;
4135 let validation_digest = digest_labeled_paths_composable(
4136 "wasm-source-inputs-v1",
4137 inputs
4138 .validation_inputs
4139 .iter()
4140 .map(|(label, path)| (label.as_path(), path.as_path())),
4141 exclusions,
4142 cache,
4143 )
4144 .map_err(|source| WasmBuildError::Io {
4145 operation: validation_operation,
4146 path: error_path.to_owned(),
4147 source,
4148 })?;
4149 let input_digest = semantic_input_digest(inputs, validation_digest, exclusions, cache)
4150 .map_err(|source| WasmBuildError::Io {
4151 operation: semantic_operation,
4152 path: error_path.to_owned(),
4153 source,
4154 })?;
4155 Ok((input_digest, validation_digest))
4156}
4157
4158fn semantic_input_digest(
4159 inputs: &ResolvedLocalInputs,
4160 validation_digest: InputDigest,
4161 exclusions: &[PathBuf],
4162 cache: &mut LabeledPathDigestCache,
4163) -> io::Result<InputDigest> {
4164 let Some(workspace) = inputs.workspace_projection else {
4165 return Ok(validation_digest);
4166 };
4167 let path_digest = digest_labeled_paths_composable(
4168 "wasm-source-inputs-v1",
4169 inputs
4170 .validation_inputs
4171 .iter()
4172 .filter(|(label, _)| !is_broad_workspace_input(label))
4173 .map(|(label, path)| (label.as_path(), path.as_path())),
4174 exclusions,
4175 cache,
4176 )?;
4177 let mut hasher = InputHasher::new("wasm-semantic-source-inputs-v1");
4178 hasher.field("path-input-digest", path_digest.as_bytes());
4179 hasher.field("workspace-projection", workspace.as_bytes());
4180 Ok(hasher.finish())
4181}
4182
4183fn workspace_configuration_inputs(
4184 spec: &WasmBuildSpec,
4185 workspace_root: &Path,
4186) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
4187 let mut inputs = Vec::new();
4188 add_if_present(
4189 &mut inputs,
4190 "workspace/Cargo.toml",
4191 workspace_root.join("Cargo.toml"),
4192 );
4193 add_if_present(
4194 &mut inputs,
4195 "workspace/Cargo.lock",
4196 workspace_root.join("Cargo.lock"),
4197 );
4198 add_if_present(
4199 &mut inputs,
4200 "workspace/rust-toolchain.toml",
4201 workspace_root.join("rust-toolchain.toml"),
4202 );
4203 add_if_present(
4204 &mut inputs,
4205 "workspace/rust-toolchain",
4206 workspace_root.join("rust-toolchain"),
4207 );
4208 append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
4209 Ok(inputs)
4210}
4211
4212fn append_cargo_configuration_inputs(
4213 inputs: &mut Vec<(PathBuf, PathBuf)>,
4214 spec: &WasmBuildSpec,
4215 workspace_root: &Path,
4216) -> Result<(), WasmBuildError> {
4217 let invocation_root =
4218 spec.workspace_root
4219 .canonicalize()
4220 .map_err(|source| WasmBuildError::Io {
4221 operation: "resolve Cargo invocation directory",
4222 path: spec.workspace_root.clone(),
4223 source,
4224 })?;
4225 let canonical_workspace =
4226 workspace_root
4227 .canonicalize()
4228 .map_err(|source| WasmBuildError::Io {
4229 operation: "resolve Cargo workspace directory",
4230 path: workspace_root.to_owned(),
4231 source,
4232 })?;
4233
4234 let cargo_home = command_environment_value(spec, "CARGO_HOME");
4235 let default_home = default_home_directory(spec);
4236 let directories = crate::cargo::environment::configuration_directories(
4237 &invocation_root,
4238 cargo_home.as_deref(),
4239 default_home.as_deref().map(Path::as_os_str),
4240 )
4241 .map_err(|source| WasmBuildError::Io {
4242 operation: "resolve Cargo configuration directories",
4243 path: invocation_root.clone(),
4244 source,
4245 })?;
4246 let roots: Vec<_> = directories
4247 .iter()
4248 .filter_map(|directory| crate::cargo::environment::preferred_configuration(directory))
4249 .collect();
4250 let observed =
4251 crate::cargo::environment::observe_configurations(&roots).map_err(|error| match error {
4252 crate::cargo::environment::ConfigurationError::Io {
4253 operation,
4254 path,
4255 source,
4256 } => WasmBuildError::Io {
4257 operation,
4258 path,
4259 source,
4260 },
4261 crate::cargo::environment::ConfigurationError::Invalid { path, message } => {
4262 WasmBuildError::InvalidCargoConfiguration { path, message }
4263 }
4264 })?;
4265 for config in observed {
4266 if !inputs.iter().any(|(_, path)| path == &config.path) {
4267 inputs.push((
4268 cargo_configuration_label(&config.location, &canonical_workspace),
4269 config.path,
4270 ));
4271 }
4272 }
4273 Ok(())
4274}
4275
4276#[cfg(windows)]
4277fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4278 command_environment_value(spec, "USERPROFILE")
4279 .or_else(|| command_environment_value(spec, "HOME"))
4280 .map(PathBuf::from)
4281}
4282
4283#[cfg(not(windows))]
4284fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4285 command_environment_value(spec, "HOME").map(PathBuf::from)
4286}
4287
4288fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
4289 spec.environment.get(OsStr::new(name)).cloned()
4290}
4291
4292fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
4293 if let Ok(relative) = config.strip_prefix(workspace_root) {
4294 return PathBuf::from("cargo-config/workspace").join(relative);
4295 }
4296 let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
4297 PathBuf::from("cargo-config/external").join(location.to_hex())
4298}
4299
4300fn append_package_inputs(
4301 inputs: &mut Vec<(PathBuf, PathBuf)>,
4302 packages: &HashMap<&str, &schema::Package>,
4303 closure: BTreeSet<&str>,
4304 workspace_root: &Path,
4305) -> Result<Vec<PathBuf>, WasmBuildError> {
4306 let mut declared_package_files = Vec::new();
4307 for id in closure {
4308 let Some(package) = packages.get(id) else {
4309 return Err(invalid_metadata(&format!(
4310 "resolved package `{id}` is missing"
4311 )));
4312 };
4313 if package.source.is_some() {
4314 continue;
4315 }
4316 let root = package.manifest_path.parent().ok_or_else(|| {
4317 invalid_metadata(&format!(
4318 "package `{}` manifest has no parent",
4319 package.name
4320 ))
4321 })?;
4322 let relative_manifest = package
4323 .manifest_path
4324 .as_std_path()
4325 .strip_prefix(workspace_root)
4326 .unwrap_or(package.manifest_path.as_std_path());
4327 let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
4328 .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
4329 inputs.push((label, root.as_std_path().to_owned()));
4330 declared_package_files.push(package.manifest_path.clone().into_std_path_buf());
4331 declared_package_files.extend(
4332 package
4333 .targets
4334 .iter()
4335 .map(|target| target.src_path.clone().into_std_path_buf()),
4336 );
4337 }
4338 Ok(declared_package_files)
4339}
4340
4341fn append_additional_inputs(
4342 inputs: &mut Vec<(PathBuf, PathBuf)>,
4343 spec: &WasmBuildSpec,
4344 workspace_root: &Path,
4345) {
4346 for additional in &spec.additional_inputs {
4347 let path = if additional.is_absolute() {
4348 additional.clone()
4349 } else {
4350 workspace_root.join(additional)
4351 };
4352 inputs.push((PathBuf::from("additional").join(additional), path));
4353 }
4354}
4355
4356fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
4357 let mut exclusions = vec![
4358 spec.target_dir.clone(),
4359 spec.workspace_root.join("target"),
4360 spec.workspace_root.join(".git"),
4361 ];
4362 if let Some(shared_target) = shared_incremental_target(spec) {
4363 exclusions.push(shared_target);
4364 }
4365 for (_, path) in inputs {
4366 if path.is_dir() {
4367 exclusions.push(path.join("target"));
4368 exclusions.push(path.join(".git"));
4369 }
4370 }
4371 exclusions
4372}
4373
4374fn validate_shared_incremental_target_boundary(
4375 spec: &WasmBuildSpec,
4376 inputs: &[(PathBuf, PathBuf)],
4377) -> Result<(), WasmBuildError> {
4378 let Some(shared_target) = shared_incremental_target(spec) else {
4379 return Ok(());
4380 };
4381 let shared_target =
4382 canonicalize_allow_missing(&shared_target).map_err(|source| WasmBuildError::Io {
4383 operation: "resolve shared incremental Cargo target boundary",
4384 path: shared_target.clone(),
4385 source,
4386 })?;
4387 let exact_entries = spec.target_dir.join(".ic-testkit/wasm-targets");
4388 let exact_entries =
4389 canonicalize_allow_missing(&exact_entries).map_err(|source| WasmBuildError::Io {
4390 operation: "resolve exact Wasm cache boundary",
4391 path: exact_entries,
4392 source,
4393 })?;
4394 if shared_target.starts_with(&exact_entries)
4397 || (exact_entries.starts_with(&shared_target)
4398 && !exact_entries.starts_with(shared_target.join(".ic-testkit")))
4399 {
4400 return Err(WasmBuildError::InvalidSpec {
4401 message: "shared incremental target must not overlap removable exact Wasm cache state"
4402 .to_owned(),
4403 });
4404 }
4405 let mut boundaries = super::inputs::boundaries::CargoInputBoundaries::default();
4406 let resolved_inputs = inputs
4407 .iter()
4408 .map(|(_, input)| {
4409 boundaries
4410 .input(
4411 input,
4412 "resolve Cargo input boundary",
4413 "inspect Cargo input boundary",
4414 )
4415 .cloned()
4416 .map_err(wasm_cache_fs_error)
4417 })
4418 .collect::<Result<Vec<_>, WasmBuildError>>()?;
4419 let has_safe_generated_root = std::iter::once(spec.target_dir.clone())
4420 .chain(std::iter::once(spec.workspace_root.join("target")))
4421 .chain(
4422 inputs
4423 .iter()
4424 .zip(&resolved_inputs)
4425 .filter(|(_, (_, is_directory))| *is_directory)
4426 .map(|((_, path), _)| path.join("target")),
4427 )
4428 .filter_map(|path| canonicalize_allow_missing(&path).ok())
4429 .filter(|root| {
4430 !resolved_inputs
4431 .iter()
4432 .any(|(input, _is_directory)| input.starts_with(root))
4433 })
4434 .fold(false, |safe, root| safe | shared_target.starts_with(root));
4436 if has_safe_generated_root {
4437 return Ok(());
4438 }
4439
4440 for (input, is_directory) in resolved_inputs {
4441 if shared_target == input
4442 || (is_directory && shared_target.starts_with(&input))
4443 || input.starts_with(&shared_target)
4444 {
4445 return Err(WasmBuildError::InvalidSpec {
4446 message: format!(
4447 "shared incremental target {} must not overlap exact Cargo inputs unless it is inside a generated target directory",
4448 shared_target.display()
4449 ),
4450 });
4451 }
4452 }
4453 Ok(())
4454}
4455
4456pub(super) fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
4457 let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
4458 return None;
4459 };
4460 Some(if target_dir.is_absolute() {
4461 target_dir.clone()
4462 } else {
4463 spec.workspace_root.join(target_dir)
4464 })
4465}
4466
4467fn shared_incremental_target_exists(
4468 spec: &WasmBuildSpec,
4469 operation: &'static str,
4470) -> Result<bool, WasmBuildError> {
4471 let target_dir =
4472 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
4473 message: "shared incremental target is not configured".to_owned(),
4474 })?;
4475 match fs::symlink_metadata(&target_dir) {
4476 Ok(metadata) if metadata.is_dir() => Ok(true),
4477 Ok(_) => Err(WasmBuildError::InvalidSpec {
4478 message: format!(
4479 "shared incremental Cargo target {} must be a directory",
4480 target_dir.display()
4481 ),
4482 }),
4483 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
4484 Err(source) => Err(WasmBuildError::Io {
4485 operation,
4486 path: target_dir,
4487 source,
4488 }),
4489 }
4490}
4491
4492fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
4493 command.env_clear().envs(&spec.environment);
4494}
4495
4496fn run_cargo_build(
4497 spec: &WasmBuildSpec,
4498 build_target_dir: &Path,
4499 targets: &[WasmArtifactTarget],
4500 progress: &mut ProgressReporter<'_>,
4501 mut spawn: &mut dyn FnMut(&mut Command) -> io::Result<OwnedChild>,
4502) -> Result<Vec<PathBuf>, WasmBuildError> {
4503 let command = cargo_build_command(spec, build_target_dir)?;
4504 communicate_cargo_build(
4505 spec,
4506 build_target_dir,
4507 targets,
4508 command,
4509 progress,
4510 &mut spawn,
4511 )
4512}
4513
4514impl WasmBuildSpec {
4515 fn cargo_invocation(&self) -> CargoInvocation {
4516 CargoInvocation {
4517 cargo_program: self.cargo_program.clone(),
4518 working_directory: self.workspace_root.clone(),
4519 environment: self.environment.clone(),
4520 target: self.target.clone(),
4521 selection: self.cargo_selection.clone(),
4522 packages: self.packages.clone(),
4523 manifest_path: None,
4524 recipe: self.example.as_ref().map_or(CargoRecipe::Library, |name| {
4525 CargoRecipe::Example(name.clone())
4526 }),
4527 configurations: Vec::new(),
4528 render_diagnostics: false,
4529 keep_going: false,
4530 }
4531 }
4532}
4533
4534fn cargo_build_command(
4535 spec: &WasmBuildSpec,
4536 build_target_dir: &Path,
4537) -> Result<Command, WasmBuildError> {
4538 let absolute_target_dir =
4539 canonicalize_allow_missing(build_target_dir).map_err(|source| WasmBuildError::Io {
4540 operation: "resolve Cargo build target directory",
4541 path: build_target_dir.to_owned(),
4542 source,
4543 })?;
4544 let mut invocation = spec.cargo_invocation();
4545 invocation.environment.insert(
4546 "CARGO_TARGET_DIR".into(),
4547 absolute_target_dir.into_os_string(),
4548 );
4549 invocation.command().map_err(|source| WasmBuildError::Io {
4550 operation: "render Cargo build invocation",
4551 path: spec.workspace_root.clone(),
4552 source,
4553 })
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;