1use crate::cargo::selection::{CargoSelection, SelectionFlag};
2use ic_host_process::{
3 child::{CleanupPolicy, OwnedChild},
4 tool::{
5 CompletedExecution, ExecutionError, ExecutionEvidence, ExecutionFailure,
6 ExecutionOperation, OutputLimit, OutputLimits, OutputStream, SuccessfulExit, ToolError,
7 capture_command, communicate_child, communicate_child_with_observer,
8 },
9};
10use serde_json::Value;
11use std::{
12 cell::RefCell,
13 collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
14 ffi::{OsStr, OsString},
15 fs::{self, File},
16 io,
17 path::{Component, Path, PathBuf},
18 process::{Command, ExitStatus, Stdio},
19 sync::{
20 Arc, RwLock,
21 atomic::{AtomicUsize, Ordering},
22 mpsc::{self, RecvTimeoutError},
23 },
24 thread,
25 time::{Duration, Instant, SystemTime},
26};
27use toml::Value as TomlValue;
28
29mod acquisition;
30use acquisition::prepare_exact_cache_entry;
31
32use super::{
33 cache_fs::{
34 ArtifactCacheMaintenance, ArtifactCachePrunePolicy, ArtifactCachePruneReport, CacheFsError,
35 RetainedCacheEntry, cache_entry_last_used, cache_maintenance_due,
36 canonicalize_allow_missing, directory_logical_size,
37 ensure_cache_directory_tag as ensure_cache_tag, is_sha256_directory, lock_cache_file,
38 lock_cache_file_with_wait_observer, perform_scheduled_cache_maintenance,
39 prune_direct_child_directories, record_cache_entry_use as record_entry_use,
40 record_cache_maintenance, remove_path_if_present, remove_unretained_entry,
41 },
42 digest::{
43 FileDigest, InputDigest, InputHasher, LabeledPathDigestCache, copy_file_atomic,
44 destination_matches_bytes, digest_bytes, digest_file, digest_labeled_paths_composable,
45 materialize_verified_output, os_bytes, read_stamp_with_limit,
46 },
47 staging::OwnedDirectory,
48};
49
50const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
51const CARGO_DIAGNOSTIC_BYTES: usize = 1024 * 1024;
53const TOOL_IDENTITY_BYTES: usize = 64 * 1024;
54const CARGO_METADATA_BYTES: usize = 16 * 1024 * 1024;
55
56const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
57const AUTOMATIC_ENVIRONMENT: &[&str] = &[
58 "CARGO_BUILD_RUSTC",
59 "CARGO_ENCODED_RUSTFLAGS",
60 "RUSTC",
61 "RUSTC_WRAPPER",
62 "RUSTC_WORKSPACE_WRAPPER",
63 "RUSTFLAGS",
64 "RUSTUP_TOOLCHAIN",
65];
66
67#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct WasmBuildSpec {
76 workspace_root: PathBuf,
77 target_dir: PathBuf,
78 packages: Vec<String>,
79 example: Option<String>,
80 cargo_selection: CargoSelection,
81 extra_env: BTreeMap<OsString, OsString>,
82 inherited_env: BTreeSet<OsString>,
83 additional_inputs: Vec<PathBuf>,
84 target: String,
85 cargo_program: OsString,
86 rustc_program: OsString,
87 cache_mode: WasmBuildCacheMode,
88 prune_policy: Option<ArtifactCachePrunePolicy>,
89 prune_interval: Option<Duration>,
90 shared_incremental_maintenance_config: Option<SharedIncrementalTargetMaintenanceConfig>,
91}
92
93#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
95pub enum SharedIncrementalTargetMaintenanceFailureMode {
96 #[default]
98 Strict,
99 BestEffort,
101}
102
103#[derive(Clone, Copy, Debug, Eq, PartialEq)]
105pub struct SharedIncrementalTargetMaintenanceConfig {
106 policy: SharedIncrementalTargetPrunePolicy,
107 minimum_interval: Duration,
108 failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
109}
110
111#[non_exhaustive]
113#[derive(Clone, Debug, Eq, PartialEq)]
114pub enum WasmBuildCacheMode {
115 Isolated,
117 SharedIncremental {
119 target_dir: PathBuf,
121 },
122}
123
124#[derive(Clone, Debug, Eq, PartialEq)]
126pub enum WasmBuildOutcome {
127 Built(WasmBuildRecord),
129 Reused(WasmBuildRecord),
131}
132
133#[derive(Clone, Debug, Eq, PartialEq)]
135pub struct WasmBuildRecord {
136 fingerprint: InputDigest,
137 input_digest: InputDigest,
138 retention: RetainedCacheEntry,
139 artifacts: Vec<PathBuf>,
140 timings: WasmBuildTimings,
141 maintenance: Option<ArtifactCacheMaintenance>,
142 shared_incremental_maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
143}
144
145#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
147pub struct WasmBuildTimings {
148 lock_wait: Duration,
149 shared_incremental_lock_wait: Option<Duration>,
150 input_resolution: WasmInputResolutionTimings,
151 cargo_build: Option<Duration>,
152 cache_maintenance: Option<Duration>,
153 total: Duration,
154}
155
156#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
158pub struct WasmInputResolutionTimings {
159 tool_identity: Duration,
160 cargo_metadata: Duration,
161 input_discovery: Duration,
162 content_hashing: Duration,
163 total: Duration,
164}
165
166#[non_exhaustive]
168#[derive(Clone, Copy, Debug, Eq, PartialEq)]
169pub enum WasmBuildFailurePhase {
170 Specification,
172 ExactCacheCoordination,
174 ToolIdentity,
176 CargoMetadata,
178 InputDiscovery,
180 ContentHashing,
182 SharedTargetCoordination,
184 SharedTargetMaintenance,
186 CargoBuild,
188 ArtifactPublication,
190 ExactCacheMaintenance,
192 Cleanup,
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: WasmBuildFailurePhase,
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, Debug, Eq, PartialEq)]
231pub struct ResolvedCargoBuildInputs {
232 fingerprint: InputDigest,
233 input_digest: InputDigest,
234 validation_digest: InputDigest,
235 inputs: Arc<[CargoBuildInput]>,
236 exclusions: Arc<[PathBuf]>,
237 wasm_artifact_error: Option<String>,
238 artifact_paths: Arc<[PathBuf]>,
239 timings: WasmInputResolutionTimings,
240}
241
242pub(super) enum WasmBuildInputReuse<'reuse> {
243 Session(&'reuse mut WasmBuildSessionState),
244 Snapshot(&'reuse WasmBuildInputSnapshotState),
245}
246
247pub(super) struct WasmBuildBatchInputResolver<'a, 'session> {
248 specs: Vec<&'a WasmBuildSpec>,
249 groups: Vec<BatchResolutionGroup>,
250 group_by_index: Vec<usize>,
251 resolved:
252 Vec<Option<Result<ResolvedCargoBuildInputs, (WasmBuildFailurePhase, WasmBuildError)>>>,
253 reuse: Option<WasmBuildInputReuse<'session>>,
254 metrics: WasmBuildBatchInputMetrics,
255}
256
257pub(super) struct WasmBuildSessionState {
258 snapshots: Vec<(WasmBuildSpec, ResolvedCargoBuildInputs)>,
259 digest_cache: LabeledPathDigestCache,
260 snapshot_reuses: usize,
261 invalidated: bool,
262}
263
264pub(super) struct WasmBuildInputSnapshotState {
265 snapshots: Vec<(WasmBuildSpec, ResolvedCargoBuildInputs)>,
266 preparation_metrics: WasmBuildBatchInputMetrics,
267 preparation_timings: WasmInputResolutionTimings,
268 reader_reuses: AtomicUsize,
269 invalidation: Arc<RwLock<bool>>,
270}
271
272pub(super) struct WasmBuildBatchAttempt {
274 pub(super) result: Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)>,
275}
276
277struct BatchResolutionGroup {
278 indexes: Vec<usize>,
279}
280
281struct ResolvedLocalInputs {
282 validation_inputs: Vec<(PathBuf, PathBuf)>,
283 workspace_projection: Option<InputDigest>,
284 wasm_artifact_error: Option<String>,
285}
286
287#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
288pub(super) struct WasmBuildBatchInputMetrics {
289 pub(super) runs: usize,
290 pub(super) reuses: usize,
291 pub(super) session_reuses: usize,
292 pub(super) prepared_reuses: usize,
293}
294
295#[derive(Eq, PartialEq)]
296struct BatchResolutionKey<'a> {
297 workspace_root: &'a Path,
298 cargo_program: &'a OsStr,
299 rustc_program: &'a OsStr,
300 metadata_arguments: &'a [OsString],
301 environment: BTreeMap<OsString, Option<OsString>>,
302}
303
304#[derive(Clone, Debug, Eq, PartialEq)]
306pub struct SharedIncrementalTargetInspection {
307 target_dir: PathBuf,
308 logical_size_bytes: u64,
309 last_used: SystemTime,
310 lock_wait: Duration,
311}
312
313#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
321pub struct SharedIncrementalTargetPrunePolicy {
322 max_age: Option<Duration>,
323 max_size_bytes: Option<u64>,
324}
325
326#[derive(Clone, Debug, Eq, PartialEq)]
328pub struct SharedIncrementalTargetMaintenance {
329 target_dir: PathBuf,
330 logical_size_bytes_before: u64,
331 logical_size_bytes_after: u64,
332 last_used_before: SystemTime,
333 cleared: bool,
334 lock_wait: Duration,
335 maintenance: Duration,
336}
337
338#[non_exhaustive]
340#[derive(Clone, Debug, Eq, PartialEq)]
341pub enum SharedIncrementalTargetMaintenanceOutcome {
342 Missing {
344 target_dir: PathBuf,
346 },
347 Skipped {
349 target_dir: PathBuf,
351 lock_wait: Duration,
353 schedule_check: Duration,
355 },
356 Performed {
358 maintenance: SharedIncrementalTargetMaintenance,
360 schedule_check: Duration,
362 },
363 Failed {
365 target_dir: PathBuf,
367 lock_wait: Duration,
369 message: String,
371 },
372}
373
374#[derive(Clone, Copy, Debug, Eq, PartialEq)]
376pub struct WasmBuildProgressConfig {
377 heartbeat_interval: Option<Duration>,
378 emit_cargo_output: bool,
379}
380
381#[derive(Clone, Copy, Debug, Eq, PartialEq)]
383pub enum WasmBuildOutputStream {
384 Stdout,
386 Stderr,
388}
389
390#[derive(Clone, Copy, Debug, Eq, PartialEq)]
392pub enum WasmBuildProgressOutcome {
393 Built,
395 Reused,
397}
398
399#[non_exhaustive]
401#[derive(Clone, Copy, Debug, Eq, PartialEq)]
402pub enum WasmBuildProgressPhase {
403 ExactCacheLock,
405 CargoIdentity,
407 RustcIdentity,
409 CargoMetadata,
411 InputDiscovery,
413 ContentHashing,
415 SharedTargetLock,
417 SharedTargetMaintenance,
419 CargoBuild,
421 ArtifactPublication,
423 ExactCacheMaintenance,
425}
426
427#[non_exhaustive]
429#[derive(Clone, Debug, Eq, PartialEq)]
430pub enum WasmBuildProgressEvent {
431 Started,
433 InputsResolved {
435 fingerprint: InputDigest,
437 input_digest: InputDigest,
439 elapsed: Duration,
441 },
442 CacheMiss {
444 fingerprint: InputDigest,
446 },
447 CacheHit {
449 fingerprint: InputDigest,
451 },
452 SharedTargetLockStarted {
454 target_dir: PathBuf,
456 },
457 SharedTargetLockAcquired {
459 target_dir: PathBuf,
461 wait: Duration,
463 },
464 SharedTargetMaintenanceStarted {
466 target_dir: PathBuf,
468 },
469 SharedTargetMaintenanceFinished {
471 outcome: SharedIncrementalTargetMaintenanceOutcome,
473 },
474 CargoStarted {
476 target_dir: PathBuf,
478 },
479 CargoOutput {
481 stream: WasmBuildOutputStream,
483 bytes: Vec<u8>,
485 },
486 Heartbeat {
488 phase: WasmBuildProgressPhase,
490 elapsed: Duration,
492 },
493 CargoFinished {
495 success: bool,
497 code: Option<i32>,
499 elapsed: Duration,
501 },
502 Finished {
504 outcome: WasmBuildProgressOutcome,
506 fingerprint: InputDigest,
508 elapsed: Duration,
510 },
511}
512
513impl Default for WasmBuildProgressConfig {
514 fn default() -> Self {
515 Self {
516 heartbeat_interval: Some(Duration::from_secs(10)),
517 emit_cargo_output: true,
518 }
519 }
520}
521
522impl WasmBuildProgressConfig {
523 #[must_use]
525 pub fn new() -> Self {
526 Self::default()
527 }
528
529 #[must_use]
533 pub const fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
534 self.heartbeat_interval = Some(interval);
535 self
536 }
537
538 #[must_use]
540 pub const fn without_heartbeats(mut self) -> Self {
541 self.heartbeat_interval = None;
542 self
543 }
544
545 #[must_use]
551 pub const fn with_cargo_output(mut self, emit: bool) -> Self {
552 self.emit_cargo_output = emit;
553 self
554 }
555
556 #[must_use]
558 pub const fn heartbeat_interval(self) -> Option<Duration> {
559 self.heartbeat_interval
560 }
561
562 #[must_use]
564 pub const fn emits_cargo_output(self) -> bool {
565 self.emit_cargo_output
566 }
567}
568
569struct ProgressReporter<'a> {
570 config: WasmBuildProgressConfig,
571 observer: Option<&'a mut dyn FnMut(WasmBuildProgressEvent)>,
572 last_event: Instant,
573 failure_phase: Option<WasmBuildFailurePhase>,
574 failure_timings: WasmBuildFailureTimings,
575}
576
577impl ProgressReporter<'_> {
578 fn silent() -> Self {
579 Self {
580 config: WasmBuildProgressConfig {
581 heartbeat_interval: None,
582 emit_cargo_output: false,
583 },
584 observer: None,
585 last_event: Instant::now(),
586 failure_phase: None,
587 failure_timings: WasmBuildFailureTimings::default(),
588 }
589 }
590
591 fn observed(
592 config: WasmBuildProgressConfig,
593 observer: &'_ mut dyn FnMut(WasmBuildProgressEvent),
594 ) -> ProgressReporter<'_> {
595 ProgressReporter {
596 config,
597 observer: Some(observer),
598 last_event: Instant::now(),
599 failure_phase: None,
600 failure_timings: WasmBuildFailureTimings::default(),
601 }
602 }
603
604 fn emit(&mut self, event: WasmBuildProgressEvent) {
605 if let Some(observer) = &mut self.observer {
606 observer(event);
607 self.last_event = Instant::now();
608 }
609 }
610
611 const fn is_observed(&self) -> bool {
612 self.observer.is_some()
613 }
614
615 fn heartbeat_due_in(&self) -> Option<Duration> {
616 self.config
617 .heartbeat_interval
618 .map(|interval| interval.saturating_sub(self.last_event.elapsed()))
619 }
620
621 fn emit_heartbeat(&mut self, phase: WasmBuildProgressPhase, elapsed: Duration) {
622 self.emit(WasmBuildProgressEvent::Heartbeat { phase, elapsed });
623 }
624
625 fn emit_heartbeat_if_due(&mut self, phase: WasmBuildProgressPhase, elapsed: Duration) {
626 if self.heartbeat_due_in() == Some(Duration::ZERO) {
627 self.emit_heartbeat(phase, elapsed);
628 }
629 }
630
631 fn run_phase<T, F>(&mut self, phase: WasmBuildProgressPhase, operation: F) -> T
632 where
633 T: Send,
634 F: FnOnce() -> T + Send,
635 {
636 let started = Instant::now();
637 self.begin_phase(progress_failure_phase(phase));
638 let result = if !self.is_observed() || self.config.heartbeat_interval.is_none() {
639 operation()
640 } else {
641 thread::scope(|scope| {
642 let (finished, completion) = mpsc::sync_channel(0);
643 let worker = scope.spawn(move || {
644 let result = operation();
645 let _ = finished.send(());
646 result
647 });
648 loop {
649 let wait = self
650 .heartbeat_due_in()
651 .expect("observed phase must have a heartbeat interval");
652 match completion.recv_timeout(wait) {
653 Ok(()) | Err(RecvTimeoutError::Disconnected) => {
654 return worker
655 .join()
656 .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
657 }
658 Err(RecvTimeoutError::Timeout) => {
659 self.emit_heartbeat(phase, started.elapsed());
660 }
661 }
662 }
663 })
664 };
665 self.record_phase(progress_failure_phase(phase), started.elapsed());
666 result
667 }
668
669 const fn begin_phase(&mut self, phase: WasmBuildFailurePhase) {
670 self.failure_phase = Some(phase);
671 }
672
673 fn record_phase(&mut self, phase: WasmBuildFailurePhase, elapsed: Duration) {
674 self.failure_phase = Some(phase);
675 let timings = &mut self.failure_timings;
676 let timing = match phase {
679 WasmBuildFailurePhase::Specification => return,
680 WasmBuildFailurePhase::ExactCacheCoordination => &mut timings.exact_cache_coordination,
681 WasmBuildFailurePhase::ToolIdentity => &mut timings.input_resolution.tool_identity,
682 WasmBuildFailurePhase::CargoMetadata => &mut timings.input_resolution.cargo_metadata,
683 WasmBuildFailurePhase::InputDiscovery => &mut timings.input_resolution.input_discovery,
684 WasmBuildFailurePhase::ContentHashing => &mut timings.input_resolution.content_hashing,
685 WasmBuildFailurePhase::SharedTargetCoordination => timings
686 .shared_target_coordination
687 .get_or_insert(Duration::ZERO),
688 WasmBuildFailurePhase::SharedTargetMaintenance => timings
689 .shared_target_maintenance
690 .get_or_insert(Duration::ZERO),
691 WasmBuildFailurePhase::CargoBuild => timings.cargo_build.get_or_insert(Duration::ZERO),
692 WasmBuildFailurePhase::ArtifactPublication => {
693 timings.artifact_publication.get_or_insert(Duration::ZERO)
694 }
695 WasmBuildFailurePhase::ExactCacheMaintenance => timings
696 .exact_cache_maintenance
697 .get_or_insert(Duration::ZERO),
698 WasmBuildFailurePhase::Cleanup => timings.cleanup.get_or_insert(Duration::ZERO),
699 };
700 *timing = timing.saturating_add(elapsed);
701 if matches!(
702 phase,
703 WasmBuildFailurePhase::ToolIdentity
704 | WasmBuildFailurePhase::CargoMetadata
705 | WasmBuildFailurePhase::InputDiscovery
706 | WasmBuildFailurePhase::ContentHashing
707 ) {
708 timings.input_resolution.total = timings.input_resolution.total.saturating_add(elapsed);
709 }
710 }
711
712 fn failure_details(&self, error: &WasmBuildError, total: Duration) -> WasmBuildFailureDetails {
713 let phase = self
714 .failure_phase
715 .unwrap_or_else(|| classify_unobserved_failure(error));
716 let mut timings = self.failure_timings;
717 timings.total = total;
718 WasmBuildFailureDetails { phase, timings }
719 }
720}
721
722const fn progress_failure_phase(phase: WasmBuildProgressPhase) -> WasmBuildFailurePhase {
723 match phase {
724 WasmBuildProgressPhase::ExactCacheLock => WasmBuildFailurePhase::ExactCacheCoordination,
725 WasmBuildProgressPhase::CargoIdentity | WasmBuildProgressPhase::RustcIdentity => {
726 WasmBuildFailurePhase::ToolIdentity
727 }
728 WasmBuildProgressPhase::CargoMetadata => WasmBuildFailurePhase::CargoMetadata,
729 WasmBuildProgressPhase::InputDiscovery => WasmBuildFailurePhase::InputDiscovery,
730 WasmBuildProgressPhase::ContentHashing => WasmBuildFailurePhase::ContentHashing,
731 WasmBuildProgressPhase::SharedTargetLock => WasmBuildFailurePhase::SharedTargetCoordination,
732 WasmBuildProgressPhase::SharedTargetMaintenance => {
733 WasmBuildFailurePhase::SharedTargetMaintenance
734 }
735 WasmBuildProgressPhase::CargoBuild => WasmBuildFailurePhase::CargoBuild,
736 WasmBuildProgressPhase::ArtifactPublication => WasmBuildFailurePhase::ArtifactPublication,
737 WasmBuildProgressPhase::ExactCacheMaintenance => {
738 WasmBuildFailurePhase::ExactCacheMaintenance
739 }
740 }
741}
742
743fn classify_unobserved_failure(error: &WasmBuildError) -> WasmBuildFailurePhase {
744 match error {
745 WasmBuildError::InvalidSpec { .. } => WasmBuildFailurePhase::Specification,
746 WasmBuildError::CommandSpawn { phase, .. }
747 | WasmBuildError::CommandFailed { phase, .. } => match phase {
748 WasmBuildPhase::CargoIdentity | WasmBuildPhase::RustcIdentity => {
749 WasmBuildFailurePhase::ToolIdentity
750 }
751 WasmBuildPhase::CargoMetadata => WasmBuildFailurePhase::CargoMetadata,
752 WasmBuildPhase::CargoBuild => WasmBuildFailurePhase::CargoBuild,
753 },
754 WasmBuildError::InvalidMetadata { .. } => WasmBuildFailurePhase::CargoMetadata,
755 WasmBuildError::InvalidCargoConfiguration { .. } => WasmBuildFailurePhase::InputDiscovery,
756 WasmBuildError::MissingArtifacts { .. } => WasmBuildFailurePhase::ArtifactPublication,
757 WasmBuildError::InputsChangedDuringAcquisition { .. } => {
758 WasmBuildFailurePhase::ContentHashing
759 }
760 WasmBuildError::PreparedInputSnapshotInvalidated => {
761 WasmBuildFailurePhase::ArtifactPublication
762 }
763 WasmBuildError::FailedBuildCleanup { .. } => WasmBuildFailurePhase::Cleanup,
764 WasmBuildError::Io { source, .. }
765 if source
766 .get_ref()
767 .is_some_and(<dyn std::error::Error + Send + Sync>::is::<ToolError>) =>
768 {
769 WasmBuildFailurePhase::CargoBuild
770 }
771 WasmBuildError::Io { .. } => WasmBuildFailurePhase::ExactCacheCoordination,
772 }
773}
774
775#[non_exhaustive]
777#[derive(Clone, Copy, Debug, Eq, PartialEq)]
778pub enum WasmBuildPhase {
779 CargoMetadata,
781 CargoIdentity,
783 RustcIdentity,
785 CargoBuild,
787}
788
789#[non_exhaustive]
791#[derive(Debug)]
792pub enum WasmBuildError {
793 InvalidSpec {
795 message: String,
797 },
798 Io {
800 operation: &'static str,
802 path: PathBuf,
804 source: io::Error,
806 },
807 CommandSpawn {
809 phase: WasmBuildPhase,
811 program: OsString,
813 source: io::Error,
815 },
816 CommandFailed {
820 phase: WasmBuildPhase,
822 status: ExitStatus,
824 stdout: String,
826 stderr: String,
828 },
829 InvalidMetadata {
831 message: String,
833 },
834 InvalidCargoConfiguration {
836 path: PathBuf,
838 message: String,
840 },
841 MissingArtifacts {
843 paths: Vec<PathBuf>,
845 },
846 InputsChangedDuringAcquisition {
848 before: InputDigest,
850 after: InputDigest,
852 },
853 PreparedInputSnapshotInvalidated,
855 FailedBuildCleanup {
857 build_error: Box<Self>,
859 path: PathBuf,
861 source: io::Error,
863 },
864}
865
866impl WasmBuildSpec {
867 #[must_use]
886 pub fn new(workspace_root: &Path, target_dir: &Path, packages: &[&str]) -> Self {
887 let mut packages = packages
888 .iter()
889 .map(|package| (*package).to_owned())
890 .collect::<Vec<_>>();
891 packages.sort();
892 packages.dedup();
893 Self {
894 workspace_root: workspace_root.to_owned(),
895 target_dir: target_dir.to_owned(),
896 packages,
897 example: None,
898 cargo_selection: CargoSelection::parse(Vec::new()),
899 extra_env: BTreeMap::new(),
900 inherited_env: BTreeSet::new(),
901 additional_inputs: Vec::new(),
902 target: DEFAULT_TARGET.to_owned(),
903 cargo_program: std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()),
904 rustc_program: std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()),
905 cache_mode: WasmBuildCacheMode::SharedIncremental {
906 target_dir: PathBuf::from("target/ic-testkit-incremental"),
907 },
908 prune_policy: None,
909 prune_interval: None,
910 shared_incremental_maintenance_config: None,
911 }
912 }
913
914 #[must_use]
920 pub fn with_example(mut self, name: impl Into<String>) -> Self {
921 self.example = Some(name.into());
922 self
923 }
924
925 #[must_use]
927 pub fn example(&self) -> Option<&str> {
928 self.example.as_deref()
929 }
930
931 pub fn cargo_build_command(&self) -> Result<Command, WasmBuildError> {
943 validate_spec(self)?;
944 cargo_build_command(self, &self.target_dir)
945 }
946
947 #[must_use]
961 pub fn with_cargo_profile_args<I, S>(mut self, arguments: I) -> Self
962 where
963 I: IntoIterator<Item = S>,
964 S: AsRef<OsStr>,
965 {
966 self.cargo_selection = CargoSelection::parse(
967 arguments
968 .into_iter()
969 .map(|argument| argument.as_ref().to_owned())
970 .collect(),
971 );
972 self
973 }
974
975 #[must_use]
983 pub fn with_extra_env<I, K, V>(mut self, environment: I) -> Self
984 where
985 I: IntoIterator<Item = (K, V)>,
986 K: Into<OsString>,
987 V: Into<OsString>,
988 {
989 self.extra_env.extend(
990 environment
991 .into_iter()
992 .map(|(key, value)| (key.into(), value.into())),
993 );
994 self
995 }
996
997 #[must_use]
1002 pub fn with_inherited_env<I, S>(mut self, names: I) -> Self
1003 where
1004 I: IntoIterator<Item = S>,
1005 S: Into<OsString>,
1006 {
1007 self.inherited_env.extend(names.into_iter().map(Into::into));
1008 self
1009 }
1010
1011 #[must_use]
1016 pub fn with_additional_inputs<I, P>(mut self, paths: I) -> Self
1017 where
1018 I: IntoIterator<Item = P>,
1019 P: Into<PathBuf>,
1020 {
1021 self.additional_inputs
1022 .extend(paths.into_iter().map(Into::into));
1023 self
1024 }
1025
1026 #[must_use]
1028 pub fn with_target(mut self, target: &str) -> Self {
1029 target.clone_into(&mut self.target);
1030 self
1031 }
1032
1033 #[must_use]
1035 pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
1036 self.cargo_program = program.into();
1037 self
1038 }
1039
1040 #[must_use]
1048 pub fn with_rustc_identity_program(mut self, program: impl Into<OsString>) -> Self {
1049 self.rustc_program = program.into();
1050 self
1051 }
1052
1053 #[must_use]
1059 pub fn with_shared_incremental_target(mut self, target_dir: impl Into<PathBuf>) -> Self {
1060 self.cache_mode = WasmBuildCacheMode::SharedIncremental {
1061 target_dir: target_dir.into(),
1062 };
1063 self
1064 }
1065
1066 #[must_use]
1071 pub fn with_isolated_builds(mut self) -> Self {
1072 self.cache_mode = WasmBuildCacheMode::Isolated;
1073 self
1074 }
1075
1076 #[must_use]
1087 pub const fn with_shared_incremental_target_maintenance_at_most_every(
1088 mut self,
1089 policy: SharedIncrementalTargetPrunePolicy,
1090 minimum_interval: Duration,
1091 ) -> Self {
1092 self.shared_incremental_maintenance_config = Some(
1093 SharedIncrementalTargetMaintenanceConfig::new(policy, minimum_interval),
1094 );
1095 self
1096 }
1097
1098 #[must_use]
1104 pub const fn with_shared_incremental_target_maintenance(
1105 mut self,
1106 config: SharedIncrementalTargetMaintenanceConfig,
1107 ) -> Self {
1108 self.shared_incremental_maintenance_config = Some(config);
1109 self
1110 }
1111
1112 #[must_use]
1118 pub const fn with_prune_policy(mut self, policy: ArtifactCachePrunePolicy) -> Self {
1119 self.prune_policy = Some(policy);
1120 self.prune_interval = None;
1121 self
1122 }
1123
1124 #[must_use]
1131 pub const fn with_prune_policy_at_most_every(
1132 mut self,
1133 policy: ArtifactCachePrunePolicy,
1134 minimum_interval: Duration,
1135 ) -> Self {
1136 self.prune_policy = Some(policy);
1137 self.prune_interval = Some(minimum_interval);
1138 self
1139 }
1140
1141 #[must_use]
1143 pub fn workspace_root(&self) -> &Path {
1144 &self.workspace_root
1145 }
1146
1147 #[must_use]
1149 pub fn target_dir(&self) -> &Path {
1150 &self.target_dir
1151 }
1152
1153 #[must_use]
1155 pub fn packages(&self) -> &[String] {
1156 &self.packages
1157 }
1158
1159 #[must_use]
1161 pub const fn cache_mode(&self) -> &WasmBuildCacheMode {
1162 &self.cache_mode
1163 }
1164
1165 #[must_use]
1167 pub const fn prune_policy(&self) -> Option<ArtifactCachePrunePolicy> {
1168 self.prune_policy
1169 }
1170
1171 #[must_use]
1173 pub const fn prune_interval(&self) -> Option<Duration> {
1174 self.prune_interval
1175 }
1176
1177 #[must_use]
1179 pub const fn shared_incremental_target_maintenance(
1180 &self,
1181 ) -> Option<SharedIncrementalTargetMaintenanceConfig> {
1182 self.shared_incremental_maintenance_config
1183 }
1184}
1185
1186impl WasmBuildOutcome {
1187 #[must_use]
1189 pub const fn record(&self) -> &WasmBuildRecord {
1190 match self {
1191 Self::Built(record) | Self::Reused(record) => record,
1192 }
1193 }
1194
1195 #[must_use]
1197 pub const fn is_reused(&self) -> bool {
1198 matches!(self, Self::Reused(_))
1199 }
1200}
1201
1202impl WasmBuildRecord {
1203 #[must_use]
1205 pub const fn fingerprint(&self) -> InputDigest {
1206 self.fingerprint
1207 }
1208
1209 #[must_use]
1211 pub const fn input_digest(&self) -> InputDigest {
1212 self.input_digest
1213 }
1214
1215 #[must_use]
1222 pub fn exact_cache_path(&self) -> &Path {
1223 self.retention.path()
1224 }
1225
1226 #[must_use]
1231 pub fn artifacts(&self) -> &[PathBuf] {
1232 &self.artifacts
1233 }
1234
1235 #[must_use]
1237 pub const fn timings(&self) -> WasmBuildTimings {
1238 self.timings
1239 }
1240
1241 #[must_use]
1243 pub const fn maintenance(&self) -> Option<&ArtifactCacheMaintenance> {
1244 self.maintenance.as_ref()
1245 }
1246
1247 #[must_use]
1249 pub const fn shared_incremental_maintenance(
1250 &self,
1251 ) -> Option<&SharedIncrementalTargetMaintenanceOutcome> {
1252 self.shared_incremental_maintenance.as_ref()
1253 }
1254}
1255
1256impl WasmBuildTimings {
1257 #[must_use]
1259 pub const fn lock_wait(self) -> Duration {
1260 self.lock_wait
1261 }
1262
1263 #[must_use]
1265 pub const fn shared_incremental_lock_wait(self) -> Option<Duration> {
1266 self.shared_incremental_lock_wait
1267 }
1268
1269 #[must_use]
1271 pub const fn input_resolution(self) -> WasmInputResolutionTimings {
1272 self.input_resolution
1273 }
1274
1275 #[must_use]
1277 pub const fn cargo_build(self) -> Option<Duration> {
1278 self.cargo_build
1279 }
1280
1281 #[must_use]
1283 pub const fn cache_maintenance(self) -> Option<Duration> {
1284 self.cache_maintenance
1285 }
1286
1287 #[must_use]
1289 pub const fn total(self) -> Duration {
1290 self.total
1291 }
1292
1293 pub(super) fn saturating_add(self, other: Self) -> Self {
1294 let mut input_resolution = self.input_resolution;
1295 input_resolution.include(other.input_resolution);
1296 Self {
1297 lock_wait: self.lock_wait.saturating_add(other.lock_wait),
1298 shared_incremental_lock_wait: [
1299 self.shared_incremental_lock_wait,
1300 other.shared_incremental_lock_wait,
1301 ]
1302 .into_iter()
1303 .flatten()
1304 .reduce(Duration::saturating_add),
1305 input_resolution,
1306 cargo_build: [self.cargo_build, other.cargo_build]
1307 .into_iter()
1308 .flatten()
1309 .reduce(Duration::saturating_add),
1310 cache_maintenance: [self.cache_maintenance, other.cache_maintenance]
1311 .into_iter()
1312 .flatten()
1313 .reduce(Duration::saturating_add),
1314 total: self.total.saturating_add(other.total),
1315 }
1316 }
1317}
1318
1319impl WasmInputResolutionTimings {
1320 #[must_use]
1322 pub const fn tool_identity(self) -> Duration {
1323 self.tool_identity
1324 }
1325
1326 #[must_use]
1328 pub const fn cargo_metadata(self) -> Duration {
1329 self.cargo_metadata
1330 }
1331
1332 #[must_use]
1334 pub const fn input_discovery(self) -> Duration {
1335 self.input_discovery
1336 }
1337
1338 #[must_use]
1340 pub const fn content_hashing(self) -> Duration {
1341 self.content_hashing
1342 }
1343
1344 #[must_use]
1346 pub const fn total(self) -> Duration {
1347 self.total
1348 }
1349
1350 const fn include(&mut self, other: Self) {
1351 self.tool_identity = self.tool_identity.saturating_add(other.tool_identity);
1352 self.cargo_metadata = self.cargo_metadata.saturating_add(other.cargo_metadata);
1353 self.input_discovery = self.input_discovery.saturating_add(other.input_discovery);
1354 self.content_hashing = self.content_hashing.saturating_add(other.content_hashing);
1355 self.total = self.total.saturating_add(other.total);
1356 }
1357}
1358
1359impl WasmBuildFailureDetails {
1360 pub(super) fn specification(total: Duration) -> Self {
1361 Self {
1362 phase: WasmBuildFailurePhase::Specification,
1363 timings: WasmBuildFailureTimings {
1364 total,
1365 ..WasmBuildFailureTimings::default()
1366 },
1367 }
1368 }
1369
1370 #[must_use]
1372 pub const fn phase(self) -> WasmBuildFailurePhase {
1373 self.phase
1374 }
1375
1376 #[must_use]
1378 pub const fn timings(self) -> WasmBuildFailureTimings {
1379 self.timings
1380 }
1381}
1382
1383impl WasmBuildFailureTimings {
1384 #[must_use]
1386 pub const fn exact_cache_coordination(self) -> Duration {
1387 self.exact_cache_coordination
1388 }
1389
1390 #[must_use]
1392 pub const fn shared_target_coordination(self) -> Option<Duration> {
1393 self.shared_target_coordination
1394 }
1395
1396 #[must_use]
1398 pub const fn input_resolution(self) -> WasmInputResolutionTimings {
1399 self.input_resolution
1400 }
1401
1402 #[must_use]
1404 pub const fn shared_target_maintenance(self) -> Option<Duration> {
1405 self.shared_target_maintenance
1406 }
1407
1408 #[must_use]
1410 pub const fn cargo_build(self) -> Option<Duration> {
1411 self.cargo_build
1412 }
1413
1414 #[must_use]
1416 pub const fn artifact_publication(self) -> Option<Duration> {
1417 self.artifact_publication
1418 }
1419
1420 #[must_use]
1422 pub const fn exact_cache_maintenance(self) -> Option<Duration> {
1423 self.exact_cache_maintenance
1424 }
1425
1426 #[must_use]
1428 pub const fn cleanup(self) -> Option<Duration> {
1429 self.cleanup
1430 }
1431
1432 #[must_use]
1434 pub const fn total(self) -> Duration {
1435 self.total
1436 }
1437}
1438
1439impl CargoBuildInput {
1440 #[must_use]
1442 pub fn label(&self) -> &Path {
1443 &self.label
1444 }
1445
1446 #[must_use]
1451 pub fn path(&self) -> &Path {
1452 &self.path
1453 }
1454}
1455
1456pub struct CargoInputProbePolicy<'a> {
1463 pub deadline: Option<Instant>,
1466 pub cleanup: CleanupPolicy,
1468 pub cancelled: &'a mut (dyn FnMut() -> bool + Send),
1471}
1472
1473impl ResolvedCargoBuildInputs {
1474 pub fn artifact_paths(&self) -> Result<&[PathBuf], WasmBuildError> {
1484 if let Some(message) = &self.wasm_artifact_error {
1485 return Err(WasmBuildError::InvalidSpec {
1486 message: message.clone(),
1487 });
1488 }
1489 Ok(&self.artifact_paths)
1490 }
1491
1492 #[must_use]
1494 pub const fn fingerprint(&self) -> InputDigest {
1495 self.fingerprint
1496 }
1497
1498 #[must_use]
1503 pub const fn input_digest(&self) -> InputDigest {
1504 self.input_digest
1505 }
1506
1507 #[must_use]
1512 pub const fn validation_digest(&self) -> InputDigest {
1513 self.validation_digest
1514 }
1515
1516 #[must_use]
1518 pub fn inputs(&self) -> &[CargoBuildInput] {
1519 &self.inputs
1520 }
1521
1522 #[must_use]
1527 pub fn exclusions(&self) -> &[PathBuf] {
1528 &self.exclusions
1529 }
1530
1531 #[must_use]
1533 pub const fn timings(&self) -> WasmInputResolutionTimings {
1534 self.timings
1535 }
1536
1537 pub fn is_current(&self, spec: &WasmBuildSpec) -> Result<bool, WasmBuildError> {
1542 resolve_cargo_build_inputs(spec).map(|current| current.fingerprint == self.fingerprint)
1543 }
1544
1545 pub fn is_current_with_probe_policy(
1553 &self,
1554 spec: &WasmBuildSpec,
1555 policy: &mut CargoInputProbePolicy<'_>,
1556 ) -> Result<bool, WasmBuildError> {
1557 resolve_cargo_build_inputs_with_probe_policy(spec, policy)
1558 .map(|current| current.fingerprint == self.fingerprint)
1559 }
1560
1561 pub fn is_content_current(&self) -> Result<bool, WasmBuildError> {
1571 self.current_validation_digest()
1572 .map(|current| current == self.validation_digest)
1573 }
1574
1575 pub(super) fn current_validation_digest(&self) -> Result<InputDigest, WasmBuildError> {
1576 digest_labeled_paths_composable(
1577 "wasm-source-inputs-v1",
1578 self.inputs
1579 .iter()
1580 .map(|input| (input.label.as_path(), input.path.as_path())),
1581 &self.exclusions,
1582 &mut LabeledPathDigestCache::default(),
1583 )
1584 .map_err(|source| WasmBuildError::Io {
1585 operation: "rehash resolved Cargo build inputs",
1586 path: self
1587 .inputs
1588 .first()
1589 .map_or_else(PathBuf::new, |input| input.path.clone()),
1590 source,
1591 })
1592 }
1593}
1594
1595impl WasmBuildSessionState {
1596 pub(super) fn new() -> Self {
1597 Self {
1598 snapshots: Vec::new(),
1599 digest_cache: LabeledPathDigestCache::default(),
1600 snapshot_reuses: 0,
1601 invalidated: false,
1602 }
1603 }
1604
1605 pub(super) const fn snapshot_count(&self) -> usize {
1606 self.snapshots.len()
1607 }
1608
1609 pub(super) const fn snapshot_reuses(&self) -> usize {
1610 self.snapshot_reuses
1611 }
1612
1613 pub(super) const fn is_invalidated(&self) -> bool {
1614 self.invalidated
1615 }
1616
1617 fn reuse(&mut self, spec: &WasmBuildSpec) -> Option<ResolvedCargoBuildInputs> {
1618 let (_, snapshot) = self
1619 .snapshots
1620 .iter()
1621 .find(|(candidate, _)| candidate == spec)?;
1622 self.snapshot_reuses = self.snapshot_reuses.saturating_add(1);
1623 Some(snapshot.clone())
1624 }
1625
1626 fn remember(&mut self, spec: &WasmBuildSpec, resolved: &ResolvedCargoBuildInputs) {
1627 if self
1628 .snapshots
1629 .iter()
1630 .any(|(candidate, _)| candidate == spec)
1631 {
1632 return;
1633 }
1634 let mut snapshot = resolved.clone();
1635 snapshot.timings = WasmInputResolutionTimings::default();
1636 self.snapshots.push((spec.clone(), snapshot));
1637 }
1638
1639 fn invalidate(&mut self) {
1640 self.snapshots.clear();
1641 self.digest_cache = LabeledPathDigestCache::default();
1642 self.invalidated = true;
1643 }
1644}
1645
1646impl WasmBuildInputSnapshotState {
1647 pub(super) fn prepare(specs: &[WasmBuildSpec]) -> Result<Self, WasmBuildError> {
1648 for spec in specs {
1649 validate_spec(spec)?;
1650 }
1651 let mut resolver = WasmBuildBatchInputResolver::new(specs, None);
1652 let mut snapshots = Vec::with_capacity(specs.len());
1653 let mut preparation_timings = WasmInputResolutionTimings::default();
1654 let mut progress = ProgressReporter::silent();
1655 for (index, spec) in specs.iter().enumerate() {
1656 let mut prepared_input = resolver.resolve(index, &mut progress)?;
1657 preparation_timings.include(prepared_input.timings);
1658 prepared_input.timings = WasmInputResolutionTimings::default();
1659 snapshots.push((spec.clone(), prepared_input));
1660 }
1661 Ok(Self {
1662 snapshots,
1663 preparation_metrics: resolver.metrics(),
1664 preparation_timings,
1665 reader_reuses: AtomicUsize::new(0),
1666 invalidation: Arc::new(RwLock::new(false)),
1667 })
1668 }
1669
1670 pub(super) fn contains(&self, spec: &WasmBuildSpec) -> bool {
1671 self.snapshots
1672 .iter()
1673 .any(|(candidate, _)| candidate == spec)
1674 }
1675
1676 fn reuse(&self, spec: &WasmBuildSpec) -> Option<ResolvedCargoBuildInputs> {
1677 let (_, snapshot) = self
1678 .snapshots
1679 .iter()
1680 .find(|(candidate, _)| candidate == spec)?;
1681 let mut current = self.reader_reuses.load(Ordering::Relaxed);
1682 loop {
1683 match self.reader_reuses.compare_exchange_weak(
1684 current,
1685 current.saturating_add(1),
1686 Ordering::Relaxed,
1687 Ordering::Relaxed,
1688 ) {
1689 Ok(_) => break,
1690 Err(observed) => current = observed,
1691 }
1692 }
1693 Some(snapshot.clone())
1694 }
1695
1696 pub(super) const fn specification_count(&self) -> usize {
1697 self.snapshots.len()
1698 }
1699
1700 pub(super) const fn preparation_metrics(&self) -> WasmBuildBatchInputMetrics {
1701 self.preparation_metrics
1702 }
1703
1704 pub(super) const fn preparation_timings(&self) -> WasmInputResolutionTimings {
1705 self.preparation_timings
1706 }
1707
1708 pub(super) fn reader_reuses(&self) -> usize {
1709 self.reader_reuses.load(Ordering::Relaxed)
1710 }
1711
1712 pub(super) fn is_invalidated(&self) -> bool {
1713 *self
1714 .invalidation
1715 .read()
1716 .unwrap_or_else(std::sync::PoisonError::into_inner)
1717 }
1718
1719 fn invalidate(&self) {
1720 *self
1721 .invalidation
1722 .write()
1723 .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
1724 }
1725
1726 fn invalidation(&self) -> Arc<RwLock<bool>> {
1727 Arc::clone(&self.invalidation)
1728 }
1729}
1730
1731impl<'a, 'session> WasmBuildBatchInputResolver<'a, 'session> {
1732 pub(super) fn new(
1733 specs: impl IntoIterator<Item = &'a WasmBuildSpec>,
1734 mut reuse: Option<WasmBuildInputReuse<'session>>,
1735 ) -> Self {
1736 let specs = specs.into_iter().collect::<Vec<_>>();
1737 let mut keys = Vec::<BatchResolutionKey>::new();
1738 let mut groups = Vec::<BatchResolutionGroup>::new();
1739 let mut group_by_index = Vec::with_capacity(specs.len());
1740 for (index, spec) in specs.iter().copied().enumerate() {
1741 let key = BatchResolutionKey::for_spec(spec);
1742 let group = keys
1743 .iter()
1744 .position(|candidate| *candidate == key)
1745 .unwrap_or_else(|| {
1746 keys.push(key);
1747 groups.push(BatchResolutionGroup {
1748 indexes: Vec::new(),
1749 });
1750 groups.len() - 1
1751 });
1752 groups[group].indexes.push(index);
1753 group_by_index.push(group);
1754 }
1755 let mut metrics = WasmBuildBatchInputMetrics::default();
1756 let resolved = specs
1757 .iter()
1758 .map(|spec| match reuse.as_mut() {
1759 Some(WasmBuildInputReuse::Session(session)) => {
1760 let reused = session.reuse(spec);
1761 if reused.is_some() {
1762 metrics.session_reuses = metrics.session_reuses.saturating_add(1);
1763 }
1764 reused.map(Ok)
1765 }
1766 Some(WasmBuildInputReuse::Snapshot(snapshot)) => {
1767 let reused = snapshot
1768 .reuse(spec)
1769 .expect("prepared input snapshot must contain every reader specification");
1770 metrics.prepared_reuses = metrics.prepared_reuses.saturating_add(1);
1771 Some(Ok(reused))
1772 }
1773 None => None,
1774 })
1775 .collect();
1776 Self {
1777 specs,
1778 groups,
1779 group_by_index,
1780 resolved,
1781 reuse,
1782 metrics,
1783 }
1784 }
1785
1786 pub(super) const fn metrics(&self) -> WasmBuildBatchInputMetrics {
1787 self.metrics
1788 }
1789
1790 pub(super) fn invalidate_source_lease(&mut self) {
1791 match self.reuse.as_mut() {
1792 Some(WasmBuildInputReuse::Session(session)) => {
1793 session.invalidate();
1794 for resolved in &mut self.resolved {
1798 *resolved = None;
1799 }
1800 self.reuse = None;
1801 }
1802 Some(WasmBuildInputReuse::Snapshot(snapshot)) => snapshot.invalidate(),
1803 None => {}
1804 }
1805 }
1806
1807 pub(super) const fn assumes_sources_immutable(&self) -> bool {
1808 self.reuse.is_some()
1809 }
1810
1811 pub(super) fn prepared_invalidation(&self) -> Option<Arc<RwLock<bool>>> {
1812 match self.reuse.as_ref() {
1813 Some(WasmBuildInputReuse::Snapshot(snapshot)) => Some(snapshot.invalidation()),
1814 _ => None,
1815 }
1816 }
1817
1818 fn resolve(
1819 &mut self,
1820 index: usize,
1821 progress: &mut ProgressReporter<'_>,
1822 ) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1823 if self.resolved[index].is_none() {
1824 self.resolve_group(index, progress)?;
1825 }
1826 self.resolved[index]
1827 .take()
1828 .expect("resolved batch input must be populated")
1829 .map_err(|(phase, error)| {
1830 progress.begin_phase(phase);
1831 error
1832 })
1833 }
1834
1835 fn resolve_group(
1836 &mut self,
1837 active_index: usize,
1838 progress: &mut ProgressReporter<'_>,
1839 ) -> Result<(), WasmBuildError> {
1840 let total_started = Instant::now();
1841 let group = self.group_by_index[active_index];
1842 let active = self.specs[active_index];
1843
1844 let (cargo_identity, rustc_identity, tool_identity) =
1845 resolve_tool_identity(active, progress)?;
1846
1847 let metadata_started = Instant::now();
1848 let metadata = progress.run_phase(WasmBuildProgressPhase::CargoMetadata, || {
1849 cargo_metadata(active)
1850 })?;
1851 let cargo_metadata = metadata_started.elapsed();
1852
1853 let (discovered, input_discovery) = self.discover_group_inputs(group, &metadata, progress);
1854
1855 let hashing_started = Instant::now();
1856 let mut batch_digest_cache = LabeledPathDigestCache::default();
1857 let digest_cache = match self.reuse.as_mut() {
1858 Some(WasmBuildInputReuse::Session(session)) => &mut session.digest_cache,
1859 _ => &mut batch_digest_cache,
1860 };
1861 let workspace_root = &active.workspace_root;
1862 let resolved_inputs = progress.run_phase(WasmBuildProgressPhase::ContentHashing, || {
1863 discovered
1864 .into_iter()
1865 .map(|(index, inputs, exclusions)| {
1866 let result = digest_resolved_local_inputs(
1867 &inputs,
1868 &exclusions,
1869 digest_cache,
1870 workspace_root,
1871 "hash batched Wasm build inputs",
1872 "hash batched semantic Wasm build inputs",
1873 )
1874 .map(|(input_digest, validation_digest)| {
1875 (
1876 inputs.validation_inputs,
1877 exclusions,
1878 input_digest,
1879 validation_digest,
1880 inputs.wasm_artifact_error,
1881 )
1882 });
1883 (index, result)
1884 })
1885 .collect::<Vec<_>>()
1886 });
1887 let content_hashing = hashing_started.elapsed();
1888 let timings = WasmInputResolutionTimings {
1889 tool_identity,
1890 cargo_metadata,
1891 input_discovery,
1892 content_hashing,
1893 total: total_started.elapsed(),
1894 };
1895 let resolved_count = resolved_inputs
1896 .iter()
1897 .filter(|(_, result)| result.is_ok())
1898 .count();
1899 self.metrics.runs += usize::from(resolved_count > 0);
1900 self.metrics.reuses += resolved_count.saturating_sub(1);
1901 let timing_index = resolved_inputs
1902 .iter()
1903 .find(|(index, result)| *index == active_index && result.is_ok())
1904 .or_else(|| resolved_inputs.iter().find(|(_, result)| result.is_ok()))
1905 .map(|(index, _)| *index);
1906 for (index, result) in resolved_inputs {
1907 let (inputs, exclusions, input_digest, validation_digest, wasm_artifact_error) =
1908 match result {
1909 Ok(resolved) => resolved,
1910 Err(error) => {
1911 self.resolved[index] =
1912 Some(Err((WasmBuildFailurePhase::ContentHashing, error)));
1913 continue;
1914 }
1915 };
1916 let spec = self.specs[index];
1917 let resolved = ResolvedCargoBuildInputs {
1918 fingerprint: finish_build_fingerprint(
1919 spec,
1920 &cargo_identity,
1921 &rustc_identity,
1922 input_digest,
1923 ),
1924 input_digest,
1925 validation_digest,
1926 inputs: cargo_build_inputs(inputs),
1927 exclusions: exclusions.into(),
1928 wasm_artifact_error,
1929 artifact_paths: expected_artifacts(spec, &spec.target_dir).into(),
1930 timings: if Some(index) == timing_index {
1931 timings
1932 } else {
1933 WasmInputResolutionTimings::default()
1934 },
1935 };
1936 if let Some(WasmBuildInputReuse::Session(session)) = self.reuse.as_mut() {
1937 session.remember(spec, &resolved);
1938 }
1939 self.resolved[index] = Some(Ok(resolved));
1940 }
1941 Ok(())
1942 }
1943
1944 fn discover_group_inputs(
1945 &mut self,
1946 group: usize,
1947 metadata: &Value,
1948 progress: &mut ProgressReporter<'_>,
1949 ) -> (Vec<(usize, ResolvedLocalInputs, Vec<PathBuf>)>, Duration) {
1950 let started = Instant::now();
1951 let pending = self.groups[group].indexes.iter().copied().filter(|index| {
1952 self.resolved[*index].is_none() && validate_spec(self.specs[*index]).is_ok()
1953 });
1954 let results = progress.run_phase(WasmBuildProgressPhase::InputDiscovery, || {
1955 let canonical = crate::cargo::observation::decode_metadata(metadata);
1956 let parsed = canonical
1957 .as_ref()
1958 .map_err(ToString::to_string)
1959 .and_then(|canonical| ParsedCargoMetadata::parse(canonical, metadata));
1960 pending
1961 .map(|index| {
1962 let spec = self.specs[index];
1963 let result = (|| {
1964 let parsed = parsed
1965 .as_ref()
1966 .map_err(|message| invalid_metadata(message))?;
1967 resolve_local_inputs(spec, parsed)
1968 })();
1969 (index, result)
1970 })
1971 .collect::<Vec<_>>()
1972 });
1973 let mut discovered = Vec::new();
1974 for (index, result) in results {
1975 match result {
1976 Ok((inputs, exclusions)) => discovered.push((index, inputs, exclusions)),
1977 Err(error) => {
1978 self.resolved[index] =
1979 Some(Err((WasmBuildFailurePhase::InputDiscovery, error)));
1980 }
1981 }
1982 }
1983 (discovered, started.elapsed())
1984 }
1985}
1986
1987fn resolve_tool_identity(
1988 spec: &WasmBuildSpec,
1989 progress: &mut ProgressReporter<'_>,
1990) -> Result<(Vec<u8>, Vec<u8>, Duration), WasmBuildError> {
1991 resolve_tool_identity_with_policy(spec, progress, &mut None)
1992}
1993
1994fn resolve_tool_identity_with_policy(
1995 spec: &WasmBuildSpec,
1996 progress: &mut ProgressReporter<'_>,
1997 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
1998) -> Result<(Vec<u8>, Vec<u8>, Duration), WasmBuildError> {
1999 let started = Instant::now();
2000 let cargo_identity = progress.run_phase(WasmBuildProgressPhase::CargoIdentity, || {
2001 command_identity_with_policy(
2002 spec,
2003 WasmBuildPhase::CargoIdentity,
2004 &spec.cargo_program,
2005 &["--version", "--verbose"],
2006 policy,
2007 )
2008 })?;
2009 let rustc_program = spec
2010 .extra_env
2011 .get(OsStr::new("RUSTC"))
2012 .unwrap_or(&spec.rustc_program);
2013 let rustc_identity = progress.run_phase(WasmBuildProgressPhase::RustcIdentity, || {
2014 command_identity_with_policy(
2015 spec,
2016 WasmBuildPhase::RustcIdentity,
2017 rustc_program,
2018 &["-vV"],
2019 policy,
2020 )
2021 })?;
2022 Ok((cargo_identity, rustc_identity, started.elapsed()))
2023}
2024
2025impl<'a> BatchResolutionKey<'a> {
2026 fn for_spec(spec: &'a WasmBuildSpec) -> Self {
2027 Self {
2028 workspace_root: &spec.workspace_root,
2029 cargo_program: &spec.cargo_program,
2030 rustc_program: spec
2031 .extra_env
2032 .get(OsStr::new("RUSTC"))
2033 .unwrap_or(&spec.rustc_program),
2034 metadata_arguments: &spec.cargo_selection.metadata_arguments,
2035 environment: effective_environment(spec),
2036 }
2037 }
2038}
2039
2040impl SharedIncrementalTargetInspection {
2041 #[must_use]
2043 pub fn target_dir(&self) -> &Path {
2044 &self.target_dir
2045 }
2046
2047 #[must_use]
2049 pub const fn logical_size_bytes(&self) -> u64 {
2050 self.logical_size_bytes
2051 }
2052
2053 #[must_use]
2055 pub const fn last_used(&self) -> SystemTime {
2056 self.last_used
2057 }
2058
2059 #[must_use]
2061 pub const fn lock_wait(&self) -> Duration {
2062 self.lock_wait
2063 }
2064}
2065
2066impl SharedIncrementalTargetPrunePolicy {
2067 #[must_use]
2069 pub const fn new() -> Self {
2070 Self {
2071 max_age: None,
2072 max_size_bytes: None,
2073 }
2074 }
2075
2076 #[must_use]
2078 pub const fn with_max_age(mut self, max_age: Duration) -> Self {
2079 self.max_age = Some(max_age);
2080 self
2081 }
2082
2083 #[must_use]
2085 pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
2086 self.max_size_bytes = Some(bytes);
2087 self
2088 }
2089
2090 #[must_use]
2092 pub const fn max_age(self) -> Option<Duration> {
2093 self.max_age
2094 }
2095
2096 #[must_use]
2098 pub const fn max_size_bytes(self) -> Option<u64> {
2099 self.max_size_bytes
2100 }
2101
2102 fn maintenance_identity(self) -> String {
2103 format!(
2104 "age={:?};size={:?}",
2105 self.max_age.map(|duration| duration.as_nanos()),
2106 self.max_size_bytes
2107 )
2108 }
2109}
2110
2111impl SharedIncrementalTargetMaintenanceConfig {
2112 #[must_use]
2114 pub const fn new(
2115 policy: SharedIncrementalTargetPrunePolicy,
2116 minimum_interval: Duration,
2117 ) -> Self {
2118 Self {
2119 policy,
2120 minimum_interval,
2121 failure_mode: SharedIncrementalTargetMaintenanceFailureMode::Strict,
2122 }
2123 }
2124
2125 #[must_use]
2127 pub const fn with_failure_mode(
2128 mut self,
2129 failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
2130 ) -> Self {
2131 self.failure_mode = failure_mode;
2132 self
2133 }
2134
2135 #[must_use]
2137 pub const fn policy(self) -> SharedIncrementalTargetPrunePolicy {
2138 self.policy
2139 }
2140
2141 #[must_use]
2143 pub const fn minimum_interval(self) -> Duration {
2144 self.minimum_interval
2145 }
2146
2147 #[must_use]
2149 pub const fn failure_mode(self) -> SharedIncrementalTargetMaintenanceFailureMode {
2150 self.failure_mode
2151 }
2152}
2153
2154impl SharedIncrementalTargetMaintenance {
2155 #[must_use]
2157 pub fn target_dir(&self) -> &Path {
2158 &self.target_dir
2159 }
2160
2161 #[must_use]
2163 pub const fn logical_size_bytes_before(&self) -> u64 {
2164 self.logical_size_bytes_before
2165 }
2166
2167 #[must_use]
2169 pub const fn logical_size_bytes_after(&self) -> u64 {
2170 self.logical_size_bytes_after
2171 }
2172
2173 #[must_use]
2175 pub const fn last_used_before(&self) -> SystemTime {
2176 self.last_used_before
2177 }
2178
2179 #[must_use]
2181 pub const fn was_cleared(&self) -> bool {
2182 self.cleared
2183 }
2184
2185 #[must_use]
2187 pub const fn lock_wait(&self) -> Duration {
2188 self.lock_wait
2189 }
2190
2191 #[must_use]
2193 pub const fn maintenance(&self) -> Duration {
2194 self.maintenance
2195 }
2196}
2197
2198impl std::fmt::Display for SharedIncrementalTargetMaintenance {
2199 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2200 write!(
2201 formatter,
2202 "target={} action={} bytes={}=>{} lock={:?} maintenance={:?}",
2203 self.target_dir.display(),
2204 if self.cleared { "cleared" } else { "retained" },
2205 self.logical_size_bytes_before,
2206 self.logical_size_bytes_after,
2207 self.lock_wait,
2208 self.maintenance,
2209 )
2210 }
2211}
2212
2213impl SharedIncrementalTargetMaintenanceOutcome {
2214 #[must_use]
2216 pub fn target_dir(&self) -> &Path {
2217 match self {
2218 Self::Missing { target_dir }
2219 | Self::Skipped { target_dir, .. }
2220 | Self::Failed { target_dir, .. } => target_dir,
2221 Self::Performed { maintenance, .. } => maintenance.target_dir(),
2222 }
2223 }
2224
2225 #[must_use]
2227 pub const fn maintenance(&self) -> Option<&SharedIncrementalTargetMaintenance> {
2228 match self {
2229 Self::Performed { maintenance, .. } => Some(maintenance),
2230 Self::Missing { .. } | Self::Skipped { .. } | Self::Failed { .. } => None,
2231 }
2232 }
2233
2234 #[must_use]
2236 pub const fn was_performed(&self) -> bool {
2237 matches!(self, Self::Performed { .. })
2238 }
2239
2240 #[must_use]
2242 pub const fn lock_wait(&self) -> Option<Duration> {
2243 match self {
2244 Self::Missing { .. } => None,
2245 Self::Skipped { lock_wait, .. } | Self::Failed { lock_wait, .. } => Some(*lock_wait),
2246 Self::Performed { maintenance, .. } => Some(maintenance.lock_wait()),
2247 }
2248 }
2249
2250 #[must_use]
2252 pub const fn schedule_check(&self) -> Option<Duration> {
2253 match self {
2254 Self::Missing { .. } | Self::Failed { .. } => None,
2255 Self::Skipped { schedule_check, .. } | Self::Performed { schedule_check, .. } => {
2256 Some(*schedule_check)
2257 }
2258 }
2259 }
2260
2261 #[must_use]
2263 pub fn failure_message(&self) -> Option<&str> {
2264 match self {
2265 Self::Failed { message, .. } => Some(message),
2266 Self::Missing { .. } | Self::Skipped { .. } | Self::Performed { .. } => None,
2267 }
2268 }
2269}
2270
2271impl std::fmt::Display for SharedIncrementalTargetMaintenanceOutcome {
2272 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2273 match self {
2274 Self::Missing { target_dir } => {
2275 write!(formatter, "target={} action=missing", target_dir.display())
2276 }
2277 Self::Skipped {
2278 target_dir,
2279 lock_wait,
2280 schedule_check,
2281 } => write!(
2282 formatter,
2283 "target={} action=skipped lock={lock_wait:?} schedule={schedule_check:?}",
2284 target_dir.display(),
2285 ),
2286 Self::Performed {
2287 maintenance,
2288 schedule_check,
2289 } => write!(formatter, "{maintenance} schedule={schedule_check:?}"),
2290 Self::Failed {
2291 target_dir,
2292 lock_wait,
2293 message,
2294 } => write!(
2295 formatter,
2296 "target={} action=failed lock={lock_wait:?} error={message}",
2297 target_dir.display(),
2298 ),
2299 }
2300 }
2301}
2302
2303impl std::fmt::Display for WasmBuildTimings {
2304 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2305 write!(
2306 formatter,
2307 "total={:?} lock={:?} shared_lock={:?} inputs={:?} cargo={:?} maintenance={:?}",
2308 self.total,
2309 self.lock_wait,
2310 self.shared_incremental_lock_wait,
2311 self.input_resolution.total,
2312 self.cargo_build,
2313 self.cache_maintenance,
2314 )
2315 }
2316}
2317
2318impl std::fmt::Display for WasmBuildOutcome {
2319 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2320 let state = if self.is_reused() { "reused" } else { "built" };
2321 write!(
2322 formatter,
2323 "{state} fingerprint={} artifacts={} {}",
2324 self.record().fingerprint,
2325 self.record().artifacts.len(),
2326 self.record().timings,
2327 )?;
2328 if let Some(maintenance) = self.record().shared_incremental_maintenance() {
2329 write!(formatter, " shared_maintenance=({maintenance})")?;
2330 }
2331 Ok(())
2332 }
2333}
2334
2335pub fn resolve_cargo_build_inputs(
2349 spec: &WasmBuildSpec,
2350) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2351 validate_spec(spec)?;
2352 build_fingerprint(spec)
2353}
2354
2355pub fn resolve_cargo_build_inputs_with_probe_policy(
2367 spec: &WasmBuildSpec,
2368 policy: &mut CargoInputProbePolicy<'_>,
2369) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2370 validate_spec(spec)?;
2371 build_fingerprint_with_policy(spec, &mut ProgressReporter::silent(), &mut Some(policy))
2372}
2373
2374pub fn inspect_shared_incremental_target(
2382 spec: &WasmBuildSpec,
2383) -> Result<Option<SharedIncrementalTargetInspection>, WasmBuildError> {
2384 if !shared_incremental_target_exists(spec, "inspect shared incremental Cargo target")? {
2385 return Ok(None);
2386 }
2387
2388 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2389 let logical_size_bytes =
2390 directory_logical_size(&canonical).map_err(|source| WasmBuildError::Io {
2391 operation: "measure shared incremental Cargo target",
2392 path: canonical.clone(),
2393 source,
2394 })?;
2395 let last_used = cache_entry_last_used(&canonical).map_err(|source| WasmBuildError::Io {
2396 operation: "read shared incremental Cargo target use time",
2397 path: canonical.clone(),
2398 source,
2399 })?;
2400 Ok(Some(SharedIncrementalTargetInspection {
2401 target_dir: canonical,
2402 logical_size_bytes,
2403 last_used,
2404 lock_wait,
2405 }))
2406}
2407
2408pub fn maintain_shared_incremental_target(
2426 spec: &WasmBuildSpec,
2427 policy: SharedIncrementalTargetPrunePolicy,
2428) -> Result<Option<SharedIncrementalTargetMaintenance>, WasmBuildError> {
2429 if !shared_incremental_target_exists(
2430 spec,
2431 "inspect shared incremental Cargo target before maintenance",
2432 )? {
2433 return Ok(None);
2434 }
2435
2436 let _ = resolve_cargo_build_inputs(spec)?;
2440 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2441 maintain_shared_incremental_target_locked(&canonical, policy, lock_wait).map(Some)
2442}
2443
2444pub fn maintain_shared_incremental_target_at_most_every(
2462 spec: &WasmBuildSpec,
2463 policy: SharedIncrementalTargetPrunePolicy,
2464 minimum_interval: Duration,
2465) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2466 let target_dir =
2467 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
2468 message: "shared incremental target is not configured".to_owned(),
2469 })?;
2470 if !shared_incremental_target_exists(
2471 spec,
2472 "inspect shared incremental Cargo target before scheduled maintenance",
2473 )? {
2474 return Ok(SharedIncrementalTargetMaintenanceOutcome::Missing { target_dir });
2475 }
2476
2477 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2478 let schedule = schedule_shared_incremental_target_maintenance(
2479 &canonical,
2480 policy,
2481 minimum_interval,
2482 lock_wait,
2483 )?;
2484 let schedule = match schedule {
2485 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => return Ok(outcome),
2486 SharedIncrementalTargetMaintenanceSchedule::Due(due) => due,
2487 };
2488
2489 let _ = resolve_cargo_build_inputs(spec)?;
2492 perform_due_shared_incremental_target_maintenance(&canonical, policy, lock_wait, schedule)
2493}
2494
2495enum SharedIncrementalTargetMaintenanceSchedule {
2496 Skipped(SharedIncrementalTargetMaintenanceOutcome),
2497 Due(DueSharedIncrementalTargetMaintenance),
2498}
2499
2500struct DueSharedIncrementalTargetMaintenance {
2501 schedule_root: PathBuf,
2502 maintenance_identity: String,
2503 schedule_check: Duration,
2504}
2505
2506fn schedule_shared_incremental_target_maintenance(
2507 canonical: &Path,
2508 policy: SharedIncrementalTargetPrunePolicy,
2509 minimum_interval: Duration,
2510 lock_wait: Duration,
2511) -> Result<SharedIncrementalTargetMaintenanceSchedule, WasmBuildError> {
2512 let schedule_root = canonical.join(".ic-testkit");
2513 let maintenance_identity = policy.maintenance_identity();
2514 let schedule_started = Instant::now();
2515 let due = cache_maintenance_due(
2516 &schedule_root,
2517 Some(minimum_interval),
2518 &maintenance_identity,
2519 )
2520 .map_err(wasm_cache_fs_error)?;
2521 let schedule_check = schedule_started.elapsed();
2522 if !due {
2523 return Ok(SharedIncrementalTargetMaintenanceSchedule::Skipped(
2524 SharedIncrementalTargetMaintenanceOutcome::Skipped {
2525 target_dir: canonical.to_owned(),
2526 lock_wait,
2527 schedule_check,
2528 },
2529 ));
2530 }
2531 Ok(SharedIncrementalTargetMaintenanceSchedule::Due(
2532 DueSharedIncrementalTargetMaintenance {
2533 schedule_root,
2534 maintenance_identity,
2535 schedule_check,
2536 },
2537 ))
2538}
2539
2540fn perform_due_shared_incremental_target_maintenance(
2541 canonical: &Path,
2542 policy: SharedIncrementalTargetPrunePolicy,
2543 lock_wait: Duration,
2544 due: DueSharedIncrementalTargetMaintenance,
2545) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2546 let DueSharedIncrementalTargetMaintenance {
2547 schedule_root,
2548 maintenance_identity,
2549 schedule_check,
2550 } = due;
2551 let maintenance = maintain_shared_incremental_target_locked(canonical, policy, lock_wait)?;
2552 record_cache_maintenance(&schedule_root, &maintenance_identity).map_err(wasm_cache_fs_error)?;
2553 Ok(SharedIncrementalTargetMaintenanceOutcome::Performed {
2554 maintenance,
2555 schedule_check,
2556 })
2557}
2558
2559fn maintain_shared_incremental_target_locked(
2560 canonical: &Path,
2561 policy: SharedIncrementalTargetPrunePolicy,
2562 lock_wait: Duration,
2563) -> Result<SharedIncrementalTargetMaintenance, WasmBuildError> {
2564 let started = Instant::now();
2565 let logical_size_bytes_before =
2566 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2567 operation: "measure shared incremental Cargo target before maintenance",
2568 path: canonical.to_owned(),
2569 source,
2570 })?;
2571 let last_used_before =
2572 cache_entry_last_used(canonical).map_err(|source| WasmBuildError::Io {
2573 operation: "read shared incremental Cargo target use time before maintenance",
2574 path: canonical.to_owned(),
2575 source,
2576 })?;
2577 let expired = policy.max_age.is_some_and(|max_age| {
2578 SystemTime::now()
2579 .duration_since(last_used_before)
2580 .is_ok_and(|age| age > max_age)
2581 });
2582 let oversized = policy
2583 .max_size_bytes
2584 .is_some_and(|max_size_bytes| logical_size_bytes_before > max_size_bytes);
2585 let cleared = expired || oversized;
2586 if cleared {
2587 clear_shared_incremental_target_contents(canonical)?;
2588 record_cache_entry_use(canonical)?;
2589 }
2590 let logical_size_bytes_after = if cleared {
2591 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2592 operation: "measure shared incremental Cargo target after maintenance",
2593 path: canonical.to_owned(),
2594 source,
2595 })?
2596 } else {
2597 logical_size_bytes_before
2598 };
2599 Ok(SharedIncrementalTargetMaintenance {
2600 target_dir: canonical.to_owned(),
2601 logical_size_bytes_before,
2602 logical_size_bytes_after,
2603 last_used_before,
2604 cleared,
2605 lock_wait,
2606 maintenance: started.elapsed(),
2607 })
2608}
2609
2610fn clear_shared_incremental_target_contents(target_dir: &Path) -> Result<(), WasmBuildError> {
2611 let entries = fs::read_dir(target_dir).map_err(|source| WasmBuildError::Io {
2612 operation: "read shared incremental Cargo target for maintenance",
2613 path: target_dir.to_owned(),
2614 source,
2615 })?;
2616 for entry in entries {
2617 let path = entry
2618 .map_err(|source| WasmBuildError::Io {
2619 operation: "read shared incremental Cargo target entry for maintenance",
2620 path: target_dir.to_owned(),
2621 source,
2622 })?
2623 .path();
2624 let preserved = path
2625 .file_name()
2626 .is_some_and(|name| name == ".ic-testkit" || name == "CACHEDIR.TAG");
2627 if !preserved {
2628 remove_path_if_present(&path).map_err(|source| WasmBuildError::Io {
2629 operation: "clear shared incremental Cargo target entry",
2630 path,
2631 source,
2632 })?;
2633 }
2634 }
2635 Ok(())
2636}
2637
2638pub fn build_wasm_canisters_cached(
2660 spec: &WasmBuildSpec,
2661) -> Result<WasmBuildOutcome, WasmBuildError> {
2662 build_wasm_canisters_cached_internal(spec, &mut ProgressReporter::silent(), None)
2663}
2664
2665pub(super) fn build_wasm_canisters_cached_in_batch(
2666 spec: &WasmBuildSpec,
2667 index: usize,
2668 resolver: &mut WasmBuildBatchInputResolver<'_, '_>,
2669) -> WasmBuildBatchAttempt {
2670 let started = Instant::now();
2671 let mut progress = ProgressReporter::silent();
2672 let result = build_wasm_canisters_cached_internal(spec, &mut progress, Some((resolver, index)));
2673 if result
2674 .as_ref()
2675 .is_err_and(WasmBuildError::indicates_input_change)
2676 {
2677 resolver.invalidate_source_lease();
2678 }
2679 batch_result(result, &progress, started.elapsed())
2680}
2681
2682pub fn build_wasm_canisters_cached_with_progress<F>(
2699 spec: &WasmBuildSpec,
2700 config: WasmBuildProgressConfig,
2701 mut observer: F,
2702) -> Result<WasmBuildOutcome, WasmBuildError>
2703where
2704 F: FnMut(WasmBuildProgressEvent),
2705{
2706 if config.heartbeat_interval == Some(Duration::ZERO) {
2707 return Err(WasmBuildError::InvalidSpec {
2708 message: "Wasm build progress heartbeat interval must be greater than zero".to_owned(),
2709 });
2710 }
2711 build_wasm_canisters_cached_internal(
2712 spec,
2713 &mut ProgressReporter::observed(config, &mut observer),
2714 None,
2715 )
2716}
2717
2718pub(super) fn build_wasm_canisters_cached_in_batch_with_progress<F>(
2719 spec: &WasmBuildSpec,
2720 index: usize,
2721 resolver: &mut WasmBuildBatchInputResolver<'_, '_>,
2722 config: WasmBuildProgressConfig,
2723 mut observer: F,
2724) -> WasmBuildBatchAttempt
2725where
2726 F: FnMut(WasmBuildProgressEvent),
2727{
2728 if config.heartbeat_interval == Some(Duration::ZERO) {
2729 return WasmBuildBatchAttempt {
2730 result: Err((
2731 WasmBuildError::InvalidSpec {
2732 message: "Wasm build progress heartbeat interval must be greater than zero"
2733 .to_owned(),
2734 },
2735 WasmBuildFailureDetails::specification(Duration::ZERO),
2736 )),
2737 };
2738 }
2739 let started = Instant::now();
2740 let mut progress = ProgressReporter::observed(config, &mut observer);
2741 let result = build_wasm_canisters_cached_internal(spec, &mut progress, Some((resolver, index)));
2742 if result
2743 .as_ref()
2744 .is_err_and(WasmBuildError::indicates_input_change)
2745 {
2746 resolver.invalidate_source_lease();
2747 }
2748 batch_result(result, &progress, started.elapsed())
2749}
2750
2751fn batch_result(
2752 result: Result<WasmBuildOutcome, WasmBuildError>,
2753 progress: &ProgressReporter<'_>,
2754 total: Duration,
2755) -> WasmBuildBatchAttempt {
2756 WasmBuildBatchAttempt {
2757 result: result.map_err(|error| {
2758 let details = progress.failure_details(&error, total);
2759 (error, details)
2760 }),
2761 }
2762}
2763
2764fn batch_source_assumptions(
2765 batch_resolution: Option<&(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2766) -> (bool, Option<Arc<RwLock<bool>>>) {
2767 batch_resolution.map_or((false, None), |(resolver, _)| {
2768 (
2769 resolver.assumes_sources_immutable(),
2770 resolver.prepared_invalidation(),
2771 )
2772 })
2773}
2774
2775fn build_wasm_canisters_cached_internal(
2776 spec: &WasmBuildSpec,
2777 progress: &mut ProgressReporter<'_>,
2778 mut batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2779) -> Result<WasmBuildOutcome, WasmBuildError> {
2780 let total_started = Instant::now();
2781 validate_spec(spec)?;
2782 let (assumes_sources_immutable, prepared_invalidation) =
2783 batch_source_assumptions(batch_resolution.as_ref());
2784 progress.emit(WasmBuildProgressEvent::Started);
2785 if spec.shared_incremental_maintenance_config.is_some() {
2786 let outcome = build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2787 spec,
2788 total_started,
2789 progress,
2790 batch_resolution.take(),
2791 )?;
2792 emit_finished_progress(&outcome, progress);
2793 return Ok(outcome);
2794 }
2795 let (cache_lock, first_lock_wait) =
2796 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2797 ensure_cache_directory_tag(&spec.target_dir)?;
2798
2799 let resolved = resolve_initial_inputs(spec, batch_resolution.take(), progress)?;
2800 let isolated_acquisition =
2801 WasmAcquisitionContext::isolated(prepared_invalidation.clone(), assumes_sources_immutable);
2802 if let Some(outcome) = try_reuse_wasm_artifacts(
2803 spec,
2804 &resolved,
2805 first_lock_wait,
2806 &isolated_acquisition,
2807 total_started,
2808 progress,
2809 )? {
2810 emit_finished_progress(&outcome, progress);
2811 return Ok(outcome);
2812 }
2813 progress.emit(WasmBuildProgressEvent::CacheMiss {
2814 fingerprint: resolved.fingerprint,
2815 });
2816
2817 let outcome = match &spec.cache_mode {
2818 WasmBuildCacheMode::Isolated => {
2819 let cache_entry = cache_entry_directory(spec, resolved.fingerprint);
2820 build_wasm_cache_miss(
2821 spec,
2822 &resolved,
2823 first_lock_wait,
2824 isolated_acquisition,
2825 &cache_entry,
2826 total_started,
2827 progress,
2828 )
2829 }
2830 WasmBuildCacheMode::SharedIncremental { .. } => {
2831 drop(cache_lock);
2832 build_wasm_with_shared_incremental(
2833 spec,
2834 resolved,
2835 first_lock_wait,
2836 assumes_sources_immutable,
2837 prepared_invalidation,
2838 total_started,
2839 progress,
2840 )
2841 }
2842 }?;
2843 emit_finished_progress(&outcome, progress);
2844 Ok(outcome)
2845}
2846
2847fn build_wasm_with_shared_incremental(
2848 spec: &WasmBuildSpec,
2849 resolved: ResolvedCargoBuildInputs,
2850 first_lock_wait: Duration,
2851 assumes_sources_immutable: bool,
2852 prepared_invalidation: Option<Arc<RwLock<bool>>>,
2853 total_started: Instant,
2854 progress: &mut ProgressReporter<'_>,
2855) -> Result<WasmBuildOutcome, WasmBuildError> {
2856 let (shared_lock, shared_lock_wait, shared_target) =
2857 lock_shared_incremental_target_with_progress(spec, progress)?;
2858 let (_cache_lock, second_lock_wait) =
2859 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2860 ensure_cache_directory_tag(&spec.target_dir)?;
2861
2862 let current = if assumes_sources_immutable {
2863 resolved
2864 } else {
2865 let mut current = resolve_inputs_with_progress(spec, progress)?;
2866 current.timings.include(resolved.timings);
2867 current
2868 };
2869 let lock_wait = first_lock_wait.saturating_add(second_lock_wait);
2870 let shared_incremental = WasmAcquisitionContext::shared(
2871 shared_lock_wait,
2872 None,
2873 prepared_invalidation,
2874 assumes_sources_immutable,
2875 );
2876 if let Some(outcome) = try_reuse_wasm_artifacts(
2877 spec,
2878 ¤t,
2879 lock_wait,
2880 &shared_incremental,
2881 total_started,
2882 progress,
2883 )? {
2884 return Ok(outcome);
2885 }
2886
2887 let outcome = build_wasm_cache_miss(
2888 spec,
2889 ¤t,
2890 lock_wait,
2891 shared_incremental,
2892 &shared_target,
2893 total_started,
2894 progress,
2895 );
2896 drop(shared_lock);
2897 outcome
2898}
2899
2900fn build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2901 spec: &WasmBuildSpec,
2902 total_started: Instant,
2903 progress: &mut ProgressReporter<'_>,
2904 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2905) -> Result<WasmBuildOutcome, WasmBuildError> {
2906 let (assumes_sources_immutable, prepared_invalidation) =
2907 batch_source_assumptions(batch_resolution.as_ref());
2908 let (_shared_lock, shared_lock_wait, shared_target) =
2909 lock_shared_incremental_target_with_progress(spec, progress)?;
2910 let (_cache_lock, lock_wait) = lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2911 ensure_cache_directory_tag(&spec.target_dir)?;
2912
2913 let resolved = resolve_initial_inputs(spec, batch_resolution, progress)?;
2916 let shared_maintenance = perform_configured_shared_incremental_target_maintenance(
2917 spec,
2918 &shared_target,
2919 shared_lock_wait,
2920 progress,
2921 )?;
2922 let shared_incremental = WasmAcquisitionContext::shared(
2923 shared_lock_wait,
2924 Some(shared_maintenance),
2925 prepared_invalidation,
2926 assumes_sources_immutable,
2927 );
2928 if let Some(outcome) = try_reuse_wasm_artifacts(
2929 spec,
2930 &resolved,
2931 lock_wait,
2932 &shared_incremental,
2933 total_started,
2934 progress,
2935 )? {
2936 return Ok(outcome);
2937 }
2938 progress.emit(WasmBuildProgressEvent::CacheMiss {
2939 fingerprint: resolved.fingerprint,
2940 });
2941 build_wasm_cache_miss(
2942 spec,
2943 &resolved,
2944 lock_wait,
2945 shared_incremental,
2946 &shared_target,
2947 total_started,
2948 progress,
2949 )
2950}
2951
2952fn perform_configured_shared_incremental_target_maintenance(
2953 spec: &WasmBuildSpec,
2954 shared_target: &Path,
2955 lock_wait: Duration,
2956 progress: &mut ProgressReporter<'_>,
2957) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2958 let config = spec
2959 .shared_incremental_maintenance_config
2960 .expect("configured shared-target maintenance must have settings");
2961 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceStarted {
2962 target_dir: shared_target.to_owned(),
2963 });
2964 let result = progress.run_phase(WasmBuildProgressPhase::SharedTargetMaintenance, || {
2965 let schedule = schedule_shared_incremental_target_maintenance(
2966 shared_target,
2967 config.policy,
2968 config.minimum_interval,
2969 lock_wait,
2970 )?;
2971 match schedule {
2972 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => Ok(outcome),
2973 SharedIncrementalTargetMaintenanceSchedule::Due(due) => {
2974 perform_due_shared_incremental_target_maintenance(
2975 shared_target,
2976 config.policy,
2977 lock_wait,
2978 due,
2979 )
2980 }
2981 }
2982 });
2983 let outcome = integrated_shared_maintenance_result(config, shared_target, lock_wait, result)?;
2984 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceFinished {
2985 outcome: outcome.clone(),
2986 });
2987 Ok(outcome)
2988}
2989
2990fn integrated_shared_maintenance_result(
2991 config: SharedIncrementalTargetMaintenanceConfig,
2992 shared_target: &Path,
2993 lock_wait: Duration,
2994 result: Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError>,
2995) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2996 match result {
2997 Ok(outcome) => Ok(outcome),
2998 Err(error)
2999 if config.failure_mode == SharedIncrementalTargetMaintenanceFailureMode::BestEffort =>
3000 {
3001 Ok(SharedIncrementalTargetMaintenanceOutcome::Failed {
3002 target_dir: shared_target.to_owned(),
3003 lock_wait,
3004 message: error.to_string(),
3005 })
3006 }
3007 Err(error) => Err(error),
3008 }
3009}
3010
3011fn resolve_inputs_with_progress(
3012 spec: &WasmBuildSpec,
3013 progress: &mut ProgressReporter<'_>,
3014) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3015 let resolved = build_fingerprint_with_progress(spec, progress)?;
3016 validate_wasm_library_outputs(&resolved, progress)?;
3017 progress.emit(WasmBuildProgressEvent::InputsResolved {
3018 fingerprint: resolved.fingerprint,
3019 input_digest: resolved.input_digest,
3020 elapsed: resolved.timings.total,
3021 });
3022 Ok(resolved)
3023}
3024
3025fn resolve_initial_inputs(
3026 spec: &WasmBuildSpec,
3027 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
3028 progress: &mut ProgressReporter<'_>,
3029) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3030 let resolved = if let Some((resolver, index)) = batch_resolution {
3031 resolver.resolve(index, progress)?
3032 } else {
3033 build_fingerprint_with_progress(spec, progress)?
3034 };
3035 validate_wasm_library_outputs(&resolved, progress)?;
3036 progress.emit(WasmBuildProgressEvent::InputsResolved {
3037 fingerprint: resolved.fingerprint,
3038 input_digest: resolved.input_digest,
3039 elapsed: resolved.timings.total,
3040 });
3041 Ok(resolved)
3042}
3043
3044fn validate_wasm_library_outputs(
3045 resolved: &ResolvedCargoBuildInputs,
3046 progress: &mut ProgressReporter<'_>,
3047) -> Result<(), WasmBuildError> {
3048 if let Some(message) = &resolved.wasm_artifact_error {
3049 progress.begin_phase(WasmBuildFailurePhase::InputDiscovery);
3050 return Err(WasmBuildError::InvalidSpec {
3051 message: message.clone(),
3052 });
3053 }
3054 Ok(())
3055}
3056
3057fn emit_finished_progress(outcome: &WasmBuildOutcome, progress: &mut ProgressReporter<'_>) {
3058 let state = if outcome.is_reused() {
3059 progress.emit(WasmBuildProgressEvent::CacheHit {
3060 fingerprint: outcome.record().fingerprint,
3061 });
3062 WasmBuildProgressOutcome::Reused
3063 } else {
3064 WasmBuildProgressOutcome::Built
3065 };
3066 progress.emit(WasmBuildProgressEvent::Finished {
3067 outcome: state,
3068 fingerprint: outcome.record().fingerprint,
3069 elapsed: outcome.record().timings.total,
3070 });
3071}
3072
3073#[derive(Clone, Debug, Default)]
3074struct WasmAcquisitionContext {
3075 assumes_sources_immutable: bool,
3076 lock_wait: Option<Duration>,
3077 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
3078 prepared_invalidation: Option<Arc<RwLock<bool>>>,
3079}
3080
3081impl WasmAcquisitionContext {
3082 fn isolated(
3083 prepared_invalidation: Option<Arc<RwLock<bool>>>,
3084 assumes_sources_immutable: bool,
3085 ) -> Self {
3086 Self {
3087 assumes_sources_immutable,
3088 prepared_invalidation,
3089 ..Self::default()
3090 }
3091 }
3092
3093 const fn shared(
3094 lock_wait: Duration,
3095 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
3096 prepared_invalidation: Option<Arc<RwLock<bool>>>,
3097 assumes_sources_immutable: bool,
3098 ) -> Self {
3099 Self {
3100 assumes_sources_immutable,
3101 lock_wait: Some(lock_wait),
3102 maintenance,
3103 prepared_invalidation,
3104 }
3105 }
3106
3107 fn lock_prepared_publication(
3108 &self,
3109 ) -> Result<Option<std::sync::RwLockReadGuard<'_, bool>>, WasmBuildError> {
3110 let guard = self.prepared_invalidation.as_deref().map(|invalidation| {
3111 invalidation
3112 .read()
3113 .unwrap_or_else(std::sync::PoisonError::into_inner)
3114 });
3115 if guard.as_deref().is_some_and(|invalidated| *invalidated) {
3116 return Err(WasmBuildError::PreparedInputSnapshotInvalidated);
3117 }
3118 Ok(guard)
3119 }
3120}
3121
3122fn try_reuse_wasm_artifacts(
3123 spec: &WasmBuildSpec,
3124 resolved: &ResolvedCargoBuildInputs,
3125 lock_wait: Duration,
3126 shared_incremental: &WasmAcquisitionContext,
3127 total_started: Instant,
3128 progress: &mut ProgressReporter<'_>,
3129) -> Result<Option<WasmBuildOutcome>, WasmBuildError> {
3130 let _publication_guard = shared_incremental.lock_prepared_publication()?;
3131 let fingerprint = resolved.fingerprint;
3132 let artifacts = expected_artifacts(spec, &spec.target_dir);
3133 let cache_entry = cache_entry_directory(spec, fingerprint);
3134 let cached_artifacts = expected_artifacts(spec, &cache_entry);
3135 let cached_info = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3136 validated_artifact_set(&cached_artifacts, fingerprint)
3137 });
3138 let artifact_info = if let Some(info) = cached_info {
3139 info
3140 } else {
3141 let public_is_current = progress
3144 .run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3145 validated_artifact_set(&artifacts, fingerprint).is_some()
3146 });
3147 if !public_is_current {
3148 return Ok(None);
3149 }
3150 reconstruct_exact_cache_entry(spec, &artifacts, &cache_entry, fingerprint, progress)?
3151 };
3152 progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3153 materialize_artifacts(
3154 &cached_artifacts,
3155 &artifacts,
3156 fingerprint,
3157 &artifact_info,
3158 true,
3159 )?;
3160 record_cache_entry_use(&cache_entry)
3161 })?;
3162 let input_resolution = validate_reused_inputs(spec, resolved, shared_incremental, progress)?;
3163 Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
3164 spec,
3165 BuildRecordInput {
3166 fingerprint,
3167 input_digest: resolved.input_digest,
3168 lock_wait,
3169 shared_incremental: shared_incremental.clone(),
3170 input_resolution,
3171 cargo_build: None,
3172 active_entry: &cache_entry,
3173 },
3174 total_started,
3175 progress,
3176 )?)))
3177}
3178
3179fn validate_reused_inputs(
3180 spec: &WasmBuildSpec,
3181 resolved: &ResolvedCargoBuildInputs,
3182 context: &WasmAcquisitionContext,
3183 progress: &mut ProgressReporter<'_>,
3184) -> Result<WasmInputResolutionTimings, WasmBuildError> {
3185 let mut timings = resolved.timings;
3186 if !context.assumes_sources_immutable {
3187 let verified = verify_resolved_inputs(spec, resolved, progress)?;
3188 timings.include(verified.timings);
3189 }
3190 Ok(timings)
3191}
3192
3193fn verify_resolved_inputs(
3194 spec: &WasmBuildSpec,
3195 resolved: &ResolvedCargoBuildInputs,
3196 progress: &mut ProgressReporter<'_>,
3197) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3198 let verified = resolve_inputs_with_progress(spec, progress)?;
3199 for (before, after) in [
3200 (resolved.validation_digest, verified.validation_digest),
3201 (resolved.fingerprint, verified.fingerprint),
3202 ] {
3203 if before != after {
3204 return Err(WasmBuildError::InputsChangedDuringAcquisition { before, after });
3205 }
3206 }
3207 Ok(verified)
3208}
3209
3210fn reconstruct_exact_cache_entry(
3211 spec: &WasmBuildSpec,
3212 artifacts: &[PathBuf],
3213 cache_entry: &Path,
3214 fingerprint: InputDigest,
3215 progress: &mut ProgressReporter<'_>,
3216) -> Result<Vec<FileDigest>, WasmBuildError> {
3217 let cached_artifacts = expected_artifacts(spec, cache_entry);
3218 let incomplete = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3219 prepare_exact_cache_entry(cache_entry)
3220 })?;
3221 let result = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3222 copy_wasm_artifacts(artifacts, &cached_artifacts)?;
3223 let missing = missing_artifacts(&cached_artifacts);
3224 if !missing.is_empty() {
3225 return Err(WasmBuildError::MissingArtifacts { paths: missing });
3226 }
3227 publish_artifact_stamps(&cached_artifacts, fingerprint)
3230 });
3231 finish_fingerprint_build(result, incomplete, progress)
3232}
3233
3234fn build_wasm_cache_miss(
3235 spec: &WasmBuildSpec,
3236 resolved: &ResolvedCargoBuildInputs,
3237 lock_wait: Duration,
3238 shared_incremental: WasmAcquisitionContext,
3239 cargo_target_dir: &Path,
3240 total_started: Instant,
3241 progress: &mut ProgressReporter<'_>,
3242) -> Result<WasmBuildOutcome, WasmBuildError> {
3243 let fingerprint = resolved.fingerprint;
3244 let mut input_resolution = resolved.timings;
3245 let artifacts = expected_artifacts(spec, &spec.target_dir);
3246 let cache_entry = cache_entry_directory(spec, fingerprint);
3247 let preparation_started = Instant::now();
3248 progress.begin_phase(WasmBuildFailurePhase::ArtifactPublication);
3249 let preparation_result = prepare_exact_cache_entry(&cache_entry);
3250 progress.record_phase(
3251 WasmBuildFailurePhase::ArtifactPublication,
3252 preparation_started.elapsed(),
3253 );
3254 let incomplete_directory = preparation_result?;
3255 let build_result = (|| {
3256 if matches!(
3257 spec.cache_mode,
3258 WasmBuildCacheMode::SharedIncremental { .. }
3259 ) {
3260 record_cache_entry_use(cargo_target_dir)?;
3261 }
3262 let build_started = Instant::now();
3263 progress.begin_phase(WasmBuildFailurePhase::CargoBuild);
3264 let cargo_result = run_cargo_build(spec, cargo_target_dir, progress);
3265 let cargo_build = build_started.elapsed();
3266 progress.record_phase(WasmBuildFailurePhase::CargoBuild, cargo_build);
3267 cargo_result?;
3268 let built_artifacts = expected_artifacts(spec, cargo_target_dir);
3269 let validation_started = Instant::now();
3270 progress.begin_phase(WasmBuildFailurePhase::ArtifactPublication);
3271 let missing = missing_artifacts(&built_artifacts);
3272 progress.record_phase(
3273 WasmBuildFailurePhase::ArtifactPublication,
3274 validation_started.elapsed(),
3275 );
3276 if !missing.is_empty() {
3277 return Err(WasmBuildError::MissingArtifacts { paths: missing });
3278 }
3279
3280 let verified = verify_resolved_inputs(spec, resolved, progress)?;
3281 input_resolution.include(verified.timings);
3282
3283 let publication_guard = shared_incremental.lock_prepared_publication()?;
3288
3289 let cached_artifacts = expected_artifacts(spec, &cache_entry);
3290 progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3291 if cargo_target_dir != cache_entry {
3292 copy_wasm_artifacts(&built_artifacts, &cached_artifacts)?;
3293 }
3294 let info = publish_artifact_stamps(&cached_artifacts, fingerprint)?;
3295 materialize_artifacts(&cached_artifacts, &artifacts, fingerprint, &info, false)?;
3296 record_cache_entry_use(&cache_entry)
3297 })?;
3298 drop(publication_guard);
3299
3300 Ok(WasmBuildOutcome::Built(complete_build_record(
3301 spec,
3302 BuildRecordInput {
3303 fingerprint,
3304 input_digest: resolved.input_digest,
3305 lock_wait,
3306 shared_incremental,
3307 input_resolution,
3308 cargo_build: Some(cargo_build),
3309 active_entry: &cache_entry,
3310 },
3311 total_started,
3312 progress,
3313 )?))
3314 })();
3315 finish_fingerprint_build(build_result, incomplete_directory, progress)
3316}
3317
3318pub fn prune_wasm_build_cache(
3330 target_dir: &Path,
3331 policy: ArtifactCachePrunePolicy,
3332) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3333 let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
3334 ensure_cache_directory_tag(target_dir)?;
3335
3336 prune_wasm_build_cache_locked(target_dir, policy, None)
3337}
3338
3339struct BuildRecordInput<'a> {
3340 fingerprint: InputDigest,
3341 input_digest: InputDigest,
3342 lock_wait: Duration,
3343 shared_incremental: WasmAcquisitionContext,
3344 input_resolution: WasmInputResolutionTimings,
3345 cargo_build: Option<Duration>,
3346 active_entry: &'a Path,
3347}
3348
3349fn complete_build_record(
3350 spec: &WasmBuildSpec,
3351 input: BuildRecordInput<'_>,
3352 total_started: Instant,
3353 progress: &mut ProgressReporter<'_>,
3354) -> Result<WasmBuildRecord, WasmBuildError> {
3355 let retention = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3356 RetainedCacheEntry::acquire(input.active_entry).map_err(wasm_cache_fs_error)
3357 })?;
3358 let (maintenance, cache_maintenance) = spec.prune_policy.map_or((None, None), |policy| {
3359 progress.run_phase(WasmBuildProgressPhase::ExactCacheMaintenance, || {
3360 let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
3361 let identity = policy.maintenance_identity();
3362 perform_scheduled_cache_maintenance(&cache_root, spec.prune_interval, &identity, || {
3363 prune_wasm_build_cache_locked(&spec.target_dir, policy, Some(input.active_entry))
3364 .map_err(|error| error.to_string())
3365 })
3366 })
3367 });
3368 Ok(WasmBuildRecord {
3369 fingerprint: input.fingerprint,
3370 input_digest: input.input_digest,
3371 artifacts: expected_artifacts(spec, input.active_entry),
3372 retention,
3373 timings: WasmBuildTimings {
3374 lock_wait: input.lock_wait,
3375 shared_incremental_lock_wait: input.shared_incremental.lock_wait,
3376 input_resolution: input.input_resolution,
3377 cargo_build: input.cargo_build,
3378 cache_maintenance,
3379 total: total_started.elapsed(),
3380 },
3381 maintenance,
3382 shared_incremental_maintenance: input.shared_incremental.maintenance,
3383 })
3384}
3385
3386fn prune_wasm_build_cache_locked(
3387 target_dir: &Path,
3388 policy: ArtifactCachePrunePolicy,
3389 protected_entry: Option<&Path>,
3390) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3391 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
3392 prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
3393 .map_err(wasm_cache_fs_error)
3394}
3395
3396fn finish_fingerprint_build<T>(
3397 result: Result<T, WasmBuildError>,
3398 incomplete_directory: OwnedDirectory,
3399 progress: &mut ProgressReporter<'_>,
3400) -> Result<T, WasmBuildError> {
3401 match result {
3402 Ok(outcome) => {
3403 incomplete_directory.preserve();
3404 Ok(outcome)
3405 }
3406 Err(build_error) => Err(cleanup_failed_fingerprint_build(
3407 build_error,
3408 incomplete_directory,
3409 progress,
3410 )),
3411 }
3412}
3413
3414fn cleanup_failed_fingerprint_build(
3415 build_error: WasmBuildError,
3416 incomplete_directory: OwnedDirectory,
3417 progress: &mut ProgressReporter<'_>,
3418) -> WasmBuildError {
3419 let path = incomplete_directory.path().to_owned();
3420 let primary_phase = progress.failure_phase;
3421 let cleanup_started = Instant::now();
3422 let cleanup = incomplete_directory.cleanup();
3423 progress.record_phase(WasmBuildFailurePhase::Cleanup, cleanup_started.elapsed());
3424 match cleanup {
3425 Ok(()) => {
3426 progress.failure_phase = primary_phase;
3427 build_error
3428 }
3429 Err(source) => WasmBuildError::FailedBuildCleanup {
3430 build_error: Box::new(build_error),
3431 path,
3432 source,
3433 },
3434 }
3435}
3436
3437fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
3438 create_dir_all(target_dir, "create Cargo target directory")?;
3439 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3440 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)
3441}
3442
3443fn lock_wasm_build_cache_with_progress(
3444 target_dir: &Path,
3445 progress: &mut ProgressReporter<'_>,
3446) -> Result<(File, Duration), WasmBuildError> {
3447 progress.begin_phase(WasmBuildFailurePhase::ExactCacheCoordination);
3448 create_dir_all(target_dir, "create Cargo target directory")?;
3449 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3450 lock_cache_file_with_progress(&lock_path, WasmBuildProgressPhase::ExactCacheLock, progress)
3451}
3452
3453fn lock_shared_incremental_target(
3454 spec: &WasmBuildSpec,
3455) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3456 lock_shared_incremental_target_internal(spec, None)
3457}
3458
3459fn lock_shared_incremental_target_with_progress(
3460 spec: &WasmBuildSpec,
3461 progress: &mut ProgressReporter<'_>,
3462) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3463 let target_dir = shared_incremental_target(spec)
3464 .expect("validated shared acquisition must have a shared Cargo target");
3465 progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted { target_dir });
3466 let (lock, wait, canonical) = lock_shared_incremental_target_internal(spec, Some(progress))?;
3467 progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
3468 target_dir: canonical.clone(),
3469 wait,
3470 });
3471 Ok((lock, wait, canonical))
3472}
3473
3474fn lock_shared_incremental_target_internal(
3475 spec: &WasmBuildSpec,
3476 mut progress: Option<&mut ProgressReporter<'_>>,
3477) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3478 if let Some(progress) = progress.as_deref_mut() {
3479 progress.begin_phase(WasmBuildFailurePhase::SharedTargetCoordination);
3480 }
3481 let target_dir =
3482 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
3483 message: "shared incremental target is not configured".to_owned(),
3484 })?;
3485 create_dir_all(
3486 &target_dir,
3487 "create shared incremental Cargo target directory",
3488 )?;
3489 ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
3490 let canonical = target_dir
3491 .canonicalize()
3492 .map_err(|source| WasmBuildError::Io {
3493 operation: "resolve shared incremental Cargo target directory",
3494 path: target_dir.clone(),
3495 source,
3496 })?;
3497 let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
3498 let (lock, wait) = if let Some(progress) = progress {
3499 lock_cache_file_with_progress(
3500 &lock_path,
3501 WasmBuildProgressPhase::SharedTargetLock,
3502 progress,
3503 )?
3504 } else {
3505 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)?
3506 };
3507 Ok((lock, wait, canonical))
3508}
3509
3510fn lock_cache_file_with_progress(
3511 lock_path: &Path,
3512 phase: WasmBuildProgressPhase,
3513 progress: &mut ProgressReporter<'_>,
3514) -> Result<(File, Duration), WasmBuildError> {
3515 let failure_phase = progress_failure_phase(phase);
3516 let started = Instant::now();
3517 progress.begin_phase(failure_phase);
3518 let result = if !progress.is_observed() || progress.config.heartbeat_interval.is_none() {
3519 lock_cache_file(lock_path).map_err(wasm_cache_fs_error)
3520 } else {
3521 let heartbeat_interval = progress
3522 .config
3523 .heartbeat_interval
3524 .expect("observed cache lock must have a heartbeat interval");
3525 lock_cache_file_with_wait_observer(lock_path, heartbeat_interval, |elapsed| {
3526 progress.emit_heartbeat_if_due(phase, elapsed);
3527 })
3528 .map_err(wasm_cache_fs_error)
3529 };
3530 progress.record_phase(failure_phase, started.elapsed());
3531 result
3532}
3533
3534fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
3535 ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
3536}
3537
3538fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
3539 record_entry_use(path).map_err(wasm_cache_fs_error)
3540}
3541
3542fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
3543 WasmBuildError::Io {
3544 operation: error.operation,
3545 path: error.path,
3546 source: error.source,
3547 }
3548}
3549
3550fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3551 if spec.packages.is_empty() {
3552 return Err(WasmBuildError::InvalidSpec {
3553 message: "at least one Cargo package is required".to_owned(),
3554 });
3555 }
3556 if spec.target.is_empty() {
3557 return Err(WasmBuildError::InvalidSpec {
3558 message: "Cargo compilation target must not be empty".to_owned(),
3559 });
3560 }
3561 if spec
3562 .cargo_selection
3563 .flags
3564 .contains(&SelectionFlag::NonBuilding)
3565 {
3566 return Err(WasmBuildError::InvalidSpec {
3567 message:
3568 "Cargo help and build-graph flags exit without building and cannot be used for Wasm acquisition"
3569 .to_owned(),
3570 });
3571 }
3572 if spec.extra_env.contains_key(OsStr::new("CARGO_TARGET_DIR"))
3573 || spec
3574 .cargo_selection
3575 .flags
3576 .contains(&SelectionFlag::TargetDirectory)
3577 {
3578 return Err(WasmBuildError::InvalidSpec {
3579 message: "Cargo target directories are owned by the build specification; use target_dir or with_shared_incremental_target instead of command overrides".to_owned(),
3580 });
3581 }
3582 validate_cargo_target_selection(spec)?;
3583 if spec
3584 .cargo_selection
3585 .flags
3586 .contains(&SelectionFlag::InputSelection)
3587 {
3588 return Err(WasmBuildError::InvalidSpec {
3589 message: "Cargo workspace, package, target, and configuration inputs are owned by the build specification; use workspace_root, packages, with_target, and discovered Cargo configuration files or with_extra_env instead of command overrides".to_owned(),
3590 });
3591 }
3592 spec.cargo_selection
3593 .profile_target_dir()
3594 .map_err(|message| WasmBuildError::InvalidSpec {
3595 message: message.to_owned(),
3596 })?;
3597 if matches!(
3598 &spec.cache_mode,
3599 WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
3600 ) {
3601 return Err(WasmBuildError::InvalidSpec {
3602 message: "shared incremental Cargo target directory must not be empty".to_owned(),
3603 });
3604 }
3605 if spec.shared_incremental_maintenance_config.is_some()
3606 && !matches!(
3607 spec.cache_mode,
3608 WasmBuildCacheMode::SharedIncremental { .. }
3609 )
3610 {
3611 return Err(WasmBuildError::InvalidSpec {
3612 message:
3613 "scheduled shared-target maintenance requires a shared incremental Cargo target"
3614 .to_owned(),
3615 });
3616 }
3617 Ok(())
3618}
3619
3620fn validate_cargo_target_selection(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3621 if let Some(example) = &spec.example {
3622 let mut components = Path::new(example).components();
3623 if spec.packages.len() != 1
3624 || !matches!(
3625 (components.next(), components.next()),
3626 (Some(Component::Normal(_)), None)
3627 )
3628 || spec.cargo_selection.flags.contains(&SelectionFlag::Library)
3629 {
3630 return Err(WasmBuildError::InvalidSpec {
3631 message: "a named Cargo example requires one package, a normal target name and no --lib override".to_owned(),
3632 });
3633 }
3634 }
3635 if spec.packages.iter().any(|package| {
3636 let mut components = Path::new(package).components();
3637 !matches!(
3638 (components.next(), components.next()),
3639 (Some(Component::Normal(_)), None)
3640 )
3641 }) {
3642 return Err(WasmBuildError::InvalidSpec {
3643 message: "Cargo package names must be normal path components".to_owned(),
3644 });
3645 }
3646 if spec
3647 .cargo_selection
3648 .flags
3649 .contains(&SelectionFlag::TargetSelection)
3650 {
3651 return Err(WasmBuildError::InvalidSpec {
3652 message: "Cargo target selection is owned by the build specification; use with_example for a named cdylib example".to_owned(),
3653 });
3654 }
3655 Ok(())
3656}
3657
3658fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3659 build_fingerprint_with_progress(spec, &mut ProgressReporter::silent())
3660}
3661
3662fn build_fingerprint_with_progress(
3663 spec: &WasmBuildSpec,
3664 progress: &mut ProgressReporter<'_>,
3665) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3666 build_fingerprint_with_policy(spec, progress, &mut None)
3667}
3668
3669fn build_fingerprint_with_policy(
3670 spec: &WasmBuildSpec,
3671 progress: &mut ProgressReporter<'_>,
3672 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3673) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3674 let total_started = Instant::now();
3675 let (cargo_identity, rustc_identity, tool_identity) =
3676 resolve_tool_identity_with_policy(spec, progress, policy)?;
3677
3678 let metadata_started = Instant::now();
3679 let metadata = progress.run_phase(WasmBuildProgressPhase::CargoMetadata, || {
3680 cargo_metadata_with_policy(spec, policy)
3681 })?;
3682 let cargo_metadata = metadata_started.elapsed();
3683
3684 let discovery_started = Instant::now();
3685 let (inputs, exclusions) =
3686 progress.run_phase(WasmBuildProgressPhase::InputDiscovery, || {
3687 let canonical = crate::cargo::observation::decode_metadata(&metadata)
3688 .map_err(|error| invalid_metadata(&error.to_string()))?;
3689 let parsed = ParsedCargoMetadata::parse(&canonical, &metadata)
3690 .map_err(|message| invalid_metadata(&message))?;
3691 resolve_local_inputs(spec, &parsed)
3692 })?;
3693 let input_discovery = discovery_started.elapsed();
3694
3695 let hashing_started = Instant::now();
3696 let (input_digest, validation_digest) =
3697 progress.run_phase(WasmBuildProgressPhase::ContentHashing, || {
3698 let mut cache = LabeledPathDigestCache::default();
3699 digest_resolved_local_inputs(
3700 &inputs,
3701 &exclusions,
3702 &mut cache,
3703 &spec.workspace_root,
3704 "hash Wasm build inputs",
3705 "hash semantic Wasm build inputs",
3706 )
3707 })?;
3708 let content_hashing = hashing_started.elapsed();
3709
3710 let fingerprint =
3711 finish_build_fingerprint(spec, &cargo_identity, &rustc_identity, input_digest);
3712 Ok(ResolvedCargoBuildInputs {
3713 fingerprint,
3714 input_digest,
3715 validation_digest,
3716 inputs: cargo_build_inputs(inputs.validation_inputs),
3717 exclusions: exclusions.into(),
3718 wasm_artifact_error: inputs.wasm_artifact_error,
3719 artifact_paths: expected_artifacts(spec, &spec.target_dir).into(),
3720 timings: WasmInputResolutionTimings {
3721 tool_identity,
3722 cargo_metadata,
3723 input_discovery,
3724 content_hashing,
3725 total: total_started.elapsed(),
3726 },
3727 })
3728}
3729
3730fn cargo_build_inputs(inputs: Vec<(PathBuf, PathBuf)>) -> Arc<[CargoBuildInput]> {
3731 inputs
3732 .into_iter()
3733 .map(|(label, path)| CargoBuildInput { label, path })
3734 .collect()
3735}
3736
3737fn finish_build_fingerprint(
3738 spec: &WasmBuildSpec,
3739 cargo_identity: &[u8],
3740 rustc_identity: &[u8],
3741 input_digest: InputDigest,
3742) -> InputDigest {
3743 let mut hasher = InputHasher::new(CACHE_FORMAT_VERSION);
3744 for package in &spec.packages {
3745 hasher.field("package", package.as_bytes());
3746 }
3747 hasher.field("target", spec.target.as_bytes());
3748 if let Some(example) = &spec.example {
3749 hasher.field("cargo-target-selection", b"example");
3750 hasher.field("cargo-example", example.as_bytes());
3751 } else {
3752 hasher.field("cargo-target-selection", b"lib");
3753 }
3754 hasher.field(
3755 "profile-target-dir",
3756 spec.cargo_selection
3757 .profile_target_dir()
3758 .expect("Cargo profile was validated before fingerprinting")
3759 .as_bytes(),
3760 );
3761 for argument in &spec.cargo_selection.arguments {
3762 hasher.field("cargo-argument", &os_bytes(argument));
3763 }
3764 for (key, value) in effective_environment(spec) {
3765 hasher.field("environment-key", &os_bytes(&key));
3766 if let Some(value) = value {
3767 hasher.field("environment-value", &os_bytes(&value));
3768 } else {
3769 hasher.field("environment-unset", b"");
3770 }
3771 }
3772 hasher.field("cargo-identity", cargo_identity);
3773 hasher.field("rustc-identity", rustc_identity);
3774 hasher.field("source-input-digest", input_digest.as_bytes());
3775 hasher.finish()
3776}
3777
3778#[cfg(test)]
3779fn command_identity(
3780 spec: &WasmBuildSpec,
3781 phase: WasmBuildPhase,
3782 program: &OsStr,
3783 arguments: &[&str],
3784) -> Result<Vec<u8>, WasmBuildError> {
3785 command_identity_with_policy(spec, phase, program, arguments, &mut None)
3786}
3787
3788fn command_identity_with_policy(
3789 spec: &WasmBuildSpec,
3790 phase: WasmBuildPhase,
3791 program: &OsStr,
3792 arguments: &[&str],
3793 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3794) -> Result<Vec<u8>, WasmBuildError> {
3795 let mut command = Command::new(program);
3796 command.current_dir(&spec.workspace_root).args(arguments);
3797 apply_command_environment(&mut command, spec);
3798 let output =
3799 capture_probe_output_with_policy(&mut command, phase, TOOL_IDENTITY_BYTES, policy)?;
3800 ensure_command_success(phase, output).map(|output| {
3801 let mut identity = output.stdout;
3802 identity.extend_from_slice(&output.stderr);
3803 identity
3804 })
3805}
3806
3807fn cargo_metadata(spec: &WasmBuildSpec) -> Result<Value, WasmBuildError> {
3808 cargo_metadata_with_policy(spec, &mut None)
3809}
3810
3811fn cargo_metadata_with_policy(
3812 spec: &WasmBuildSpec,
3813 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3814) -> Result<Value, WasmBuildError> {
3815 let mut command = Command::new(&spec.cargo_program);
3816 command
3817 .current_dir(&spec.workspace_root)
3818 .args(["metadata", "--format-version", "1"]);
3819 for argument in &spec.cargo_selection.metadata_arguments {
3820 command.arg(argument);
3821 }
3822 apply_command_environment(&mut command, spec);
3823 let output = capture_probe_output_with_policy(
3824 &mut command,
3825 WasmBuildPhase::CargoMetadata,
3826 CARGO_METADATA_BYTES,
3827 policy,
3828 )?;
3829 let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
3830 serde_json::from_slice(&output.stdout).map_err(|error| WasmBuildError::InvalidMetadata {
3831 message: format!("Cargo metadata was not valid JSON: {error}"),
3832 })
3833}
3834
3835#[cfg(test)]
3836fn capture_probe_output(
3837 command: &mut Command,
3838 phase: WasmBuildPhase,
3839 stdout_bytes: usize,
3840) -> Result<CompletedExecution, WasmBuildError> {
3841 capture_probe_output_with_policy(command, phase, stdout_bytes, &mut None)
3842}
3843
3844fn capture_probe_output_with_policy(
3845 command: &mut Command,
3846 phase: WasmBuildPhase,
3847 stdout_bytes: usize,
3848 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3849) -> Result<CompletedExecution, WasmBuildError> {
3850 let limits = OutputLimits {
3851 stdout: OutputLimit::Terminate(stdout_bytes),
3852 stderr: OutputLimit::Terminate(TOOL_IDENTITY_BYTES),
3853 timeout: None,
3854 };
3855 let result = if let Some(policy) = policy {
3856 capture_controlled_probe(command, limits, policy)
3857 } else {
3858 capture_command(command, limits)
3859 }
3860 .and_then(CompletedExecution::require_complete);
3861 let evidence = match result {
3862 Ok(evidence) => evidence,
3863 Err(ToolError::Execution(error))
3864 if matches!(
3865 error.failure,
3866 ExecutionFailure::Io {
3867 operation: ExecutionOperation::Spawn,
3868 ..
3869 }
3870 ) && error.cleanup.is_none() =>
3871 {
3872 let ExecutionFailure::Io { source, .. } = error.failure else {
3873 unreachable!()
3874 };
3875 return Err(WasmBuildError::CommandSpawn {
3876 phase,
3877 program: command.get_program().to_owned(),
3878 source,
3879 });
3880 }
3881 Err(error) => {
3882 return Err(WasmBuildError::Io {
3883 operation: match phase {
3884 WasmBuildPhase::CargoIdentity => "capture complete Cargo identity probe output",
3885 WasmBuildPhase::RustcIdentity => "capture complete rustc identity probe output",
3886 _ => "capture complete Cargo metadata probe output",
3887 },
3888 path: PathBuf::from(command.get_program()),
3889 source: io::Error::other(error),
3890 });
3891 }
3892 };
3893 Ok(evidence)
3894}
3895
3896fn capture_controlled_probe(
3897 command: &mut Command,
3898 mut limits: OutputLimits,
3899 policy: &mut CargoInputProbePolicy<'_>,
3900) -> Result<CompletedExecution, ToolError> {
3901 let failure = if (policy.cancelled)() {
3902 Some(ExecutionFailure::Cancelled)
3903 } else if policy
3904 .deadline
3905 .is_some_and(|deadline| deadline <= Instant::now())
3906 {
3907 Some(ExecutionFailure::TimedOut)
3908 } else {
3909 None
3910 };
3911 if let Some(failure) = failure {
3912 return Err(ToolError::Execution(Box::new(ExecutionError {
3913 failure,
3914 evidence: ExecutionEvidence::default(),
3915 cleanup: None,
3916 })));
3917 }
3918 command
3919 .stdin(Stdio::null())
3920 .stdout(Stdio::piped())
3921 .stderr(Stdio::piped());
3922 let mut child = OwnedChild::spawn_with_cleanup(command, policy.cleanup).map_err(|source| {
3923 ToolError::Execution(Box::new(ExecutionError {
3924 failure: ExecutionFailure::Io {
3925 operation: ExecutionOperation::Spawn,
3926 source,
3927 },
3928 evidence: ExecutionEvidence::default(),
3929 cleanup: None,
3930 }))
3931 })?;
3932 limits.timeout = policy.deadline.map(|deadline| {
3934 deadline
3935 .saturating_duration_since(Instant::now())
3936 .max(Duration::from_nanos(1))
3937 });
3938 communicate_child(
3939 &mut child,
3940 None,
3941 limits,
3942 SuccessfulExit::Cleanup,
3943 &mut *policy.cancelled,
3944 )
3945}
3946
3947struct MetadataPackage<'a> {
3950 package: &'a crate::cargo::observation::schema::Package,
3951 original: &'a Value,
3952}
3953
3954struct ParsedCargoMetadata<'a> {
3957 packages: HashMap<&'a str, MetadataPackage<'a>>,
3958 workspace_members: HashSet<&'a str>,
3959 nodes: HashMap<&'a str, MetadataNode<'a>>,
3960 workspace_root: &'a Path,
3961}
3962
3963struct MetadataNode<'a> {
3964 node: &'a crate::cargo::observation::schema::Node,
3965 value: &'a Value,
3966}
3967
3968impl<'a> ParsedCargoMetadata<'a> {
3969 fn parse(
3970 metadata: &'a crate::cargo::observation::schema::Metadata,
3971 original: &'a Value,
3972 ) -> Result<Self, String> {
3973 let packages = metadata_packages(metadata, original);
3974 let workspace_members = metadata
3975 .workspace_members
3976 .iter()
3977 .map(|id| id.repr.as_str())
3978 .collect();
3979 let resolve = metadata
3980 .resolve
3981 .as_ref()
3982 .ok_or_else(|| "Cargo metadata has no resolved dependency nodes".to_owned())?;
3983 let nodes = resolve
3984 .nodes
3985 .iter()
3986 .enumerate()
3987 .map(|(index, node)| {
3988 (
3989 node.id.repr.as_str(),
3990 MetadataNode {
3991 node,
3992 value: &original["resolve"]["nodes"][index],
3993 },
3994 )
3995 })
3996 .collect();
3997 Ok(Self {
3998 packages,
3999 workspace_members,
4000 nodes,
4001 workspace_root: metadata.workspace_root.as_std_path(),
4002 })
4003 }
4004}
4005
4006const SEMANTIC_PACKAGE_FIELDS: &[&str] = &[
4007 "authors",
4008 "default_run",
4009 "description",
4010 "documentation",
4011 "edition",
4012 "homepage",
4013 "license",
4014 "license_file",
4015 "links",
4016 "metadata",
4017 "name",
4018 "readme",
4019 "repository",
4020 "rust_version",
4021 "version",
4022];
4023
4024struct LockedPackageIdentity {
4025 name: String,
4026 version: String,
4027 source: String,
4028 checksum: Option<String>,
4029}
4030
4031fn resolve_local_inputs(
4032 spec: &WasmBuildSpec,
4033 metadata: &ParsedCargoMetadata<'_>,
4034) -> Result<(ResolvedLocalInputs, Vec<PathBuf>), WasmBuildError> {
4035 let mut selected_ids = selected_package_ids(spec, metadata)?;
4036 let mut artifact_names = BTreeSet::new();
4039 let wasm_artifact_error = selected_ids.iter().find_map(|id| {
4040 let package = metadata.packages[id].package;
4041 if let Some(example) = &spec.example {
4042 return (!package.targets.iter().any(|target| {
4043 target.name == *example && target.is_example() && target.crate_types.contains(&crate::cargo::observation::schema::CrateType::CDyLib)
4044 })).then(|| {
4045 format!("Cargo package `{}` must declare the cdylib example `{example}`", package.name)
4046 });
4047 }
4048 let library = default_library_name(package.name.as_str());
4049 if package.targets.iter().find(|target| target.is_cdylib()).is_none_or(|target| target.name != library) {
4050 Some(format!(
4051 "Cargo package `{}` must declare a cdylib library named `{}` to produce its expected Wasm artifact",
4052 package.name, library,
4053 ))
4054 } else if !artifact_names.insert(library.clone()) {
4055 Some(format!("selected Cargo packages share the Wasm artifact name `{library}`"))
4056 } else {
4057 None
4058 }
4059 });
4060 let mut closure = BTreeSet::new();
4061 while let Some(id) = selected_ids.pop_front() {
4062 if !closure.insert(id) {
4063 continue;
4064 }
4065 if let Some(node) = metadata.nodes.get(id) {
4066 selected_ids.extend(
4067 node.node
4068 .deps
4069 .iter()
4070 .map(|dependency| dependency.pkg.repr.as_str()),
4071 );
4072 }
4073 }
4074
4075 let workspace_root = metadata.workspace_root;
4076 let workspace_projection = semantic_workspace_projection(metadata, &closure, workspace_root)?;
4077 let mut validation_inputs = workspace_configuration_inputs(spec, workspace_root)?;
4078 append_package_inputs(
4079 &mut validation_inputs,
4080 &metadata.packages,
4081 closure,
4082 workspace_root,
4083 )?;
4084 append_additional_inputs(&mut validation_inputs, spec, workspace_root);
4085 validate_shared_incremental_target_boundary(spec, &validation_inputs)?;
4086 let exclusions = source_exclusions(spec, &validation_inputs);
4087 Ok((
4088 ResolvedLocalInputs {
4089 validation_inputs,
4090 workspace_projection,
4091 wasm_artifact_error,
4092 },
4093 exclusions,
4094 ))
4095}
4096
4097fn metadata_packages<'a>(
4098 metadata: &'a crate::cargo::observation::schema::Metadata,
4099 original: &'a Value,
4100) -> HashMap<&'a str, MetadataPackage<'a>> {
4101 metadata
4102 .packages
4103 .iter()
4104 .enumerate()
4105 .map(|(index, package)| {
4106 (
4107 package.id.repr.as_str(),
4108 MetadataPackage {
4109 package,
4110 original: &original["packages"][index],
4111 },
4112 )
4113 })
4114 .collect()
4115}
4116
4117fn selected_package_ids<'a>(
4118 spec: &WasmBuildSpec,
4119 metadata: &ParsedCargoMetadata<'a>,
4120) -> Result<VecDeque<&'a str>, WasmBuildError> {
4121 let mut selected_ids = VecDeque::new();
4122 for requested in &spec.packages {
4123 let mut matches = metadata
4124 .packages
4125 .values()
4126 .filter(|package| {
4127 package.package.name == *requested
4128 && metadata
4129 .workspace_members
4130 .contains(package.package.id.repr.as_str())
4131 })
4132 .map(|package| package.package.id.repr.as_str());
4133 match (matches.next(), matches.next()) {
4134 (Some(id), None) => selected_ids.push_back(id),
4135 (None, _) => {
4136 return Err(WasmBuildError::InvalidSpec {
4137 message: format!("Cargo workspace contains no package named `{requested}`"),
4138 });
4139 }
4140 _ => {
4141 return Err(WasmBuildError::InvalidSpec {
4142 message: format!("Cargo workspace package name `{requested}` is ambiguous"),
4143 });
4144 }
4145 }
4146 }
4147 Ok(selected_ids)
4148}
4149
4150fn semantic_workspace_projection(
4151 metadata: &ParsedCargoMetadata<'_>,
4152 closure: &BTreeSet<&str>,
4153 workspace_root: &Path,
4154) -> Result<Option<InputDigest>, WasmBuildError> {
4155 let locked_packages = locked_package_identities(workspace_root)?;
4158 let mut identities = HashMap::new();
4159 for &id in closure {
4160 let package = metadata
4161 .packages
4162 .get(id)
4163 .ok_or_else(|| invalid_metadata(&format!("resolved package `{id}` is missing")))?;
4164 let Some(identity) = semantic_package_identity(package, workspace_root, &locked_packages)
4165 else {
4166 return Ok(None);
4167 };
4168 identities.insert(id, identity);
4169 }
4170
4171 let mut projected_packages = closure
4172 .iter()
4173 .map(|&id| {
4174 let package = metadata
4175 .packages
4176 .get(id)
4177 .expect("selected package closure was validated above");
4178 let identity = identities[id];
4179 let node = metadata.nodes.get(id).ok_or_else(|| {
4180 invalid_metadata(&format!("resolved package `{id}` has no dependency node"))
4181 })?;
4182 let projection = semantic_package_projection(package, node, &identities)?;
4183 Ok::<_, WasmBuildError>((identity, projection))
4184 })
4185 .collect::<Result<Vec<_>, _>>()?;
4186 projected_packages.sort_by_key(|(identity, _)| *identity);
4187
4188 let root_manifest = workspace_root.join("Cargo.toml");
4189 let root_contents =
4190 fs::read_to_string(&root_manifest).map_err(|source| WasmBuildError::Io {
4191 operation: "read workspace manifest for semantic projection",
4192 path: root_manifest.clone(),
4193 source,
4194 })?;
4195 let root = toml::from_str::<TomlValue>(&root_contents).map_err(|error| {
4196 invalid_metadata(&format!(
4197 "workspace manifest could not be projected as TOML: {error}"
4198 ))
4199 })?;
4200
4201 let mut hasher = InputHasher::new("wasm-semantic-workspace-projection-v1");
4202 for (identity, projection) in projected_packages {
4203 hasher.field("package-identity", identity.as_bytes());
4204 hasher.field("package-projection", projection.as_bytes());
4205 }
4206 hash_toml_setting(&mut hasher, "cargo-features", root.get("cargo-features"));
4207 hash_toml_setting(&mut hasher, "profile", root.get("profile"));
4208 let workspace = root.get("workspace").and_then(TomlValue::as_table);
4209 hash_toml_setting(
4210 &mut hasher,
4211 "workspace-resolver",
4212 workspace.and_then(|table| table.get("resolver")),
4213 );
4214 hash_toml_setting(
4215 &mut hasher,
4216 "workspace-lints",
4217 workspace.and_then(|table| table.get("lints")),
4218 );
4219 Ok(Some(hasher.finish()))
4220}
4221
4222fn locked_package_identities(
4223 workspace_root: &Path,
4224) -> Result<Vec<LockedPackageIdentity>, WasmBuildError> {
4225 let lockfile = workspace_root.join("Cargo.lock");
4226 let contents = match fs::read_to_string(&lockfile) {
4227 Ok(contents) => contents,
4228 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
4229 Err(source) => {
4230 return Err(WasmBuildError::Io {
4231 operation: "read Cargo lockfile for semantic projection",
4232 path: lockfile,
4233 source,
4234 });
4235 }
4236 };
4237 let lock = toml::from_str::<TomlValue>(&contents).map_err(|error| {
4238 invalid_metadata(&format!(
4239 "Cargo lockfile could not be projected as TOML: {error}"
4240 ))
4241 })?;
4242 let Some(packages) = lock.get("package").and_then(TomlValue::as_array) else {
4243 return Ok(Vec::new());
4244 };
4245 packages
4246 .iter()
4247 .filter_map(|package| {
4248 let Some(table) = package.as_table() else {
4249 return Some(Err(invalid_metadata(
4250 "Cargo lockfile package entry is not a table",
4251 )));
4252 };
4253 let source = table.get("source")?.as_str().map(str::to_owned);
4254 Some(
4255 source
4256 .ok_or_else(|| {
4257 invalid_metadata("Cargo lockfile package source is not a string")
4258 })
4259 .and_then(|source| {
4260 Ok(LockedPackageIdentity {
4261 name: required_toml_string(table, "name", "Cargo lockfile package")?,
4262 version: required_toml_string(
4263 table,
4264 "version",
4265 "Cargo lockfile package",
4266 )?,
4267 source,
4268 checksum: optional_toml_string(
4269 table,
4270 "checksum",
4271 "Cargo lockfile package",
4272 )?,
4273 })
4274 }),
4275 )
4276 })
4277 .collect()
4278}
4279
4280fn required_toml_string(
4281 table: &toml::Table,
4282 field: &str,
4283 context: &str,
4284) -> Result<String, WasmBuildError> {
4285 table
4286 .get(field)
4287 .and_then(TomlValue::as_str)
4288 .map(str::to_owned)
4289 .ok_or_else(|| invalid_metadata(&format!("{context} `{field}` is missing or not a string")))
4290}
4291
4292fn optional_toml_string(
4293 table: &toml::Table,
4294 field: &str,
4295 context: &str,
4296) -> Result<Option<String>, WasmBuildError> {
4297 match table.get(field) {
4298 None => Ok(None),
4299 Some(TomlValue::String(value)) => Ok(Some(value.clone())),
4300 Some(_) => Err(invalid_metadata(&format!(
4301 "{context} `{field}` is not a string"
4302 ))),
4303 }
4304}
4305
4306fn semantic_package_identity(
4307 package: &MetadataPackage<'_>,
4308 workspace_root: &Path,
4309 locked_packages: &[LockedPackageIdentity],
4310) -> Option<InputDigest> {
4311 let package = package.package;
4312 let mut hasher = InputHasher::new("wasm-semantic-package-identity-v1");
4313 hasher.field("name", package.name.as_bytes());
4314 hasher.field("version", package.version.to_string().as_bytes());
4315 if package.source.is_none() {
4316 let manifest = package.manifest_path.strip_prefix(workspace_root).ok()?;
4317 let package_root = package.manifest_path.parent()?;
4318 if package_root == workspace_root {
4319 return None;
4320 }
4321 hasher.field("local-manifest", &os_bytes(manifest.as_os_str()));
4322 } else {
4323 let metadata_source = &package.source.as_ref()?.repr;
4324 let locked = locked_packages.iter().find(|locked| {
4325 package.name == locked.name
4326 && locked.version == package.version.to_string()
4327 && locked.source == *metadata_source
4328 })?;
4329 match locked.source.as_str() {
4330 source if source.starts_with("registry+") && locked.checksum.is_some() => {}
4331 source if source.starts_with("git+") && source.contains('#') => {}
4332 _ => return None,
4333 }
4334 hasher.field("external-package-id", package.id.repr.as_bytes());
4335 hasher.field("external-source", locked.source.as_bytes());
4336 hasher.field(
4337 "external-checksum",
4338 locked.checksum.as_deref().unwrap_or_default().as_bytes(),
4339 );
4340 }
4341 Some(hasher.finish())
4342}
4343
4344fn semantic_package_projection(
4345 package: &MetadataPackage<'_>,
4346 node: &MetadataNode<'_>,
4347 identities: &HashMap<&str, InputDigest>,
4348) -> Result<InputDigest, WasmBuildError> {
4349 let mut hasher = InputHasher::new("wasm-semantic-package-projection-v1");
4353 for field in SEMANTIC_PACKAGE_FIELDS {
4354 hasher.field("package-field-name", field.as_bytes());
4355 match package.original.get(*field) {
4356 Some(value) => hasher.field("package-field-value", value.to_string().as_bytes()),
4357 None => hasher.field("package-field-missing", b""),
4358 }
4359 }
4360
4361 let mut features = node
4362 .node
4363 .features
4364 .iter()
4365 .map(|feature| feature.as_str())
4366 .collect::<Vec<_>>();
4367 features.sort_unstable();
4368 for feature in features {
4369 hasher.field("enabled-feature", feature.as_bytes());
4370 }
4371
4372 let mut dependencies = node
4373 .node
4374 .deps
4375 .iter()
4376 .enumerate()
4377 .map(|(index, dependency)| {
4378 let name = dependency.name.as_str();
4379 let package_id = dependency.pkg.repr.as_str();
4380 let identity = identities.get(package_id).copied().ok_or_else(|| {
4381 invalid_metadata(&format!(
4382 "dependency `{package_id}` is outside the selected package closure"
4383 ))
4384 })?;
4385 let kinds = node.value["deps"][index]
4388 .get("dep_kinds")
4389 .ok_or_else(|| invalid_metadata("Cargo metadata dependency has no kind array"))?
4390 .to_string();
4391 Ok::<_, WasmBuildError>((name, identity, kinds))
4392 })
4393 .collect::<Result<Vec<_>, _>>()?;
4394 dependencies.sort();
4395 for (name, identity, kinds) in dependencies {
4396 hasher.field("dependency-name", name.as_bytes());
4397 hasher.field("dependency-identity", identity.as_bytes());
4398 hasher.field("dependency-kinds", kinds.as_bytes());
4399 }
4400 Ok(hasher.finish())
4401}
4402
4403fn hash_toml_setting(hasher: &mut InputHasher, label: &str, value: Option<&TomlValue>) {
4404 hasher.field("workspace-setting-name", label.as_bytes());
4405 match value {
4406 Some(value) => hasher.field("workspace-setting-value", value.to_string().as_bytes()),
4407 None => hasher.field("workspace-setting-missing", b""),
4408 }
4409}
4410
4411fn is_broad_workspace_input(label: &Path) -> bool {
4412 label == Path::new("workspace/Cargo.toml") || label == Path::new("workspace/Cargo.lock")
4413}
4414
4415fn digest_resolved_local_inputs(
4416 inputs: &ResolvedLocalInputs,
4417 exclusions: &[PathBuf],
4418 cache: &mut LabeledPathDigestCache,
4419 error_path: &Path,
4420 validation_operation: &'static str,
4421 semantic_operation: &'static str,
4422) -> Result<(InputDigest, InputDigest), WasmBuildError> {
4423 let validation_digest = digest_labeled_paths_composable(
4424 "wasm-source-inputs-v1",
4425 inputs
4426 .validation_inputs
4427 .iter()
4428 .map(|(label, path)| (label.as_path(), path.as_path())),
4429 exclusions,
4430 cache,
4431 )
4432 .map_err(|source| WasmBuildError::Io {
4433 operation: validation_operation,
4434 path: error_path.to_owned(),
4435 source,
4436 })?;
4437 let input_digest = semantic_input_digest(inputs, validation_digest, exclusions, cache)
4438 .map_err(|source| WasmBuildError::Io {
4439 operation: semantic_operation,
4440 path: error_path.to_owned(),
4441 source,
4442 })?;
4443 Ok((input_digest, validation_digest))
4444}
4445
4446fn semantic_input_digest(
4447 inputs: &ResolvedLocalInputs,
4448 validation_digest: InputDigest,
4449 exclusions: &[PathBuf],
4450 cache: &mut LabeledPathDigestCache,
4451) -> io::Result<InputDigest> {
4452 let Some(workspace) = inputs.workspace_projection else {
4453 return Ok(validation_digest);
4454 };
4455 let path_digest = digest_labeled_paths_composable(
4456 "wasm-source-inputs-v1",
4457 inputs
4458 .validation_inputs
4459 .iter()
4460 .filter(|(label, _)| !is_broad_workspace_input(label))
4461 .map(|(label, path)| (label.as_path(), path.as_path())),
4462 exclusions,
4463 cache,
4464 )?;
4465 let mut hasher = InputHasher::new("wasm-semantic-source-inputs-v1");
4466 hasher.field("path-input-digest", path_digest.as_bytes());
4467 hasher.field("workspace-projection", workspace.as_bytes());
4468 Ok(hasher.finish())
4469}
4470
4471fn workspace_configuration_inputs(
4472 spec: &WasmBuildSpec,
4473 workspace_root: &Path,
4474) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
4475 let mut inputs = Vec::new();
4476 add_if_present(
4477 &mut inputs,
4478 "workspace/Cargo.toml",
4479 workspace_root.join("Cargo.toml"),
4480 );
4481 add_if_present(
4482 &mut inputs,
4483 "workspace/Cargo.lock",
4484 workspace_root.join("Cargo.lock"),
4485 );
4486 add_if_present(
4487 &mut inputs,
4488 "workspace/rust-toolchain.toml",
4489 workspace_root.join("rust-toolchain.toml"),
4490 );
4491 add_if_present(
4492 &mut inputs,
4493 "workspace/rust-toolchain",
4494 workspace_root.join("rust-toolchain"),
4495 );
4496 append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
4497 Ok(inputs)
4498}
4499
4500fn append_cargo_configuration_inputs(
4501 inputs: &mut Vec<(PathBuf, PathBuf)>,
4502 spec: &WasmBuildSpec,
4503 workspace_root: &Path,
4504) -> Result<(), WasmBuildError> {
4505 let invocation_root =
4506 spec.workspace_root
4507 .canonicalize()
4508 .map_err(|source| WasmBuildError::Io {
4509 operation: "resolve Cargo invocation directory",
4510 path: spec.workspace_root.clone(),
4511 source,
4512 })?;
4513 let canonical_workspace =
4514 workspace_root
4515 .canonicalize()
4516 .map_err(|source| WasmBuildError::Io {
4517 operation: "resolve Cargo workspace directory",
4518 path: workspace_root.to_owned(),
4519 source,
4520 })?;
4521
4522 let cargo_home = command_environment_value(spec, "CARGO_HOME");
4523 let default_home = default_home_directory(spec);
4524 let directories = crate::cargo::environment::configuration_directories(
4525 &invocation_root,
4526 cargo_home.as_deref(),
4527 default_home.as_deref().map(Path::as_os_str),
4528 )
4529 .map_err(|source| WasmBuildError::Io {
4530 operation: "resolve Cargo configuration directories",
4531 path: invocation_root.clone(),
4532 source,
4533 })?;
4534 let roots: Vec<_> = directories
4535 .iter()
4536 .filter_map(|directory| crate::cargo::environment::preferred_configuration(directory))
4537 .collect();
4538 let observed =
4539 crate::cargo::environment::observe_configurations(&roots).map_err(|error| match error {
4540 crate::cargo::environment::ConfigurationError::Io {
4541 operation,
4542 path,
4543 source,
4544 } => WasmBuildError::Io {
4545 operation,
4546 path,
4547 source,
4548 },
4549 crate::cargo::environment::ConfigurationError::Invalid { path, message } => {
4550 WasmBuildError::InvalidCargoConfiguration { path, message }
4551 }
4552 })?;
4553 for config in observed {
4554 if !inputs.iter().any(|(_, path)| path == &config.path) {
4555 inputs.push((
4556 cargo_configuration_label(&config.location, &canonical_workspace),
4557 config.path,
4558 ));
4559 }
4560 }
4561 Ok(())
4562}
4563
4564#[cfg(windows)]
4565fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4566 command_environment_value(spec, "USERPROFILE")
4567 .or_else(|| command_environment_value(spec, "HOME"))
4568 .map(PathBuf::from)
4569}
4570
4571#[cfg(not(windows))]
4572fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4573 command_environment_value(spec, "HOME").map(PathBuf::from)
4574}
4575
4576fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
4577 spec.extra_env
4578 .get(OsStr::new(name))
4579 .cloned()
4580 .or_else(|| std::env::var_os(name))
4581}
4582
4583fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
4584 if let Ok(relative) = config.strip_prefix(workspace_root) {
4585 return PathBuf::from("cargo-config/workspace").join(relative);
4586 }
4587 let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
4588 PathBuf::from("cargo-config/external").join(location.to_hex())
4589}
4590
4591fn append_package_inputs(
4592 inputs: &mut Vec<(PathBuf, PathBuf)>,
4593 packages: &HashMap<&str, MetadataPackage<'_>>,
4594 closure: BTreeSet<&str>,
4595 workspace_root: &Path,
4596) -> Result<(), WasmBuildError> {
4597 for id in closure {
4598 let Some(package) = packages.get(id) else {
4599 return Err(invalid_metadata(&format!(
4600 "resolved package `{id}` is missing"
4601 )));
4602 };
4603 let package = package.package;
4604 if package.source.is_some() {
4605 continue;
4606 }
4607 let root = package.manifest_path.parent().ok_or_else(|| {
4608 invalid_metadata(&format!(
4609 "package `{}` manifest has no parent",
4610 package.name
4611 ))
4612 })?;
4613 let relative_manifest = package
4614 .manifest_path
4615 .as_std_path()
4616 .strip_prefix(workspace_root)
4617 .unwrap_or(package.manifest_path.as_std_path());
4618 let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
4619 .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
4620 inputs.push((label, root.as_std_path().to_owned()));
4621 }
4622 Ok(())
4623}
4624
4625fn append_additional_inputs(
4626 inputs: &mut Vec<(PathBuf, PathBuf)>,
4627 spec: &WasmBuildSpec,
4628 workspace_root: &Path,
4629) {
4630 for additional in &spec.additional_inputs {
4631 let path = if additional.is_absolute() {
4632 additional.clone()
4633 } else {
4634 workspace_root.join(additional)
4635 };
4636 inputs.push((PathBuf::from("additional").join(additional), path));
4637 }
4638}
4639
4640fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
4641 let mut exclusions = vec![
4642 spec.target_dir.clone(),
4643 spec.workspace_root.join("target"),
4644 spec.workspace_root.join(".git"),
4645 ];
4646 if let Some(shared_target) = shared_incremental_target(spec) {
4647 exclusions.push(shared_target);
4648 }
4649 for (_, path) in inputs {
4650 if path.is_dir() {
4651 exclusions.push(path.join("target"));
4652 exclusions.push(path.join(".git"));
4653 }
4654 }
4655 exclusions
4656}
4657
4658fn validate_shared_incremental_target_boundary(
4659 spec: &WasmBuildSpec,
4660 inputs: &[(PathBuf, PathBuf)],
4661) -> Result<(), WasmBuildError> {
4662 let Some(shared_target) = shared_incremental_target(spec) else {
4663 return Ok(());
4664 };
4665 let shared_target =
4666 canonicalize_allow_missing(&shared_target).map_err(|source| WasmBuildError::Io {
4667 operation: "resolve shared incremental Cargo target boundary",
4668 path: shared_target.clone(),
4669 source,
4670 })?;
4671 let exact_entries = spec.target_dir.join(".ic-testkit/wasm-targets");
4672 let exact_entries =
4673 canonicalize_allow_missing(&exact_entries).map_err(|source| WasmBuildError::Io {
4674 operation: "resolve exact Wasm cache boundary",
4675 path: exact_entries,
4676 source,
4677 })?;
4678 if shared_target.starts_with(&exact_entries)
4681 || (exact_entries.starts_with(&shared_target)
4682 && !exact_entries.starts_with(shared_target.join(".ic-testkit")))
4683 {
4684 return Err(WasmBuildError::InvalidSpec {
4685 message: "shared incremental target must not overlap removable exact Wasm cache state"
4686 .to_owned(),
4687 });
4688 }
4689 let resolved_inputs = inputs
4690 .iter()
4691 .map(|(_, input)| {
4692 let canonical = input.canonicalize().map_err(|source| WasmBuildError::Io {
4693 operation: "resolve Cargo input boundary",
4694 path: input.clone(),
4695 source,
4696 })?;
4697 let metadata = fs::metadata(&canonical).map_err(|source| WasmBuildError::Io {
4698 operation: "inspect Cargo input boundary",
4699 path: canonical.clone(),
4700 source,
4701 })?;
4702 Ok((canonical, metadata.is_dir()))
4703 })
4704 .collect::<Result<Vec<_>, WasmBuildError>>()?;
4705 let safe_generated_roots = std::iter::once(spec.target_dir.clone())
4706 .chain(std::iter::once(spec.workspace_root.join("target")))
4707 .chain(
4708 inputs
4709 .iter()
4710 .filter(|(_, path)| path.is_dir())
4711 .map(|(_, path)| path.join("target")),
4712 )
4713 .filter_map(|path| canonicalize_allow_missing(&path).ok())
4714 .filter(|root| {
4715 !resolved_inputs
4716 .iter()
4717 .any(|(input, _is_directory)| input.starts_with(root))
4718 })
4719 .collect::<Vec<_>>();
4720 if safe_generated_roots
4721 .iter()
4722 .any(|root| shared_target.starts_with(root))
4723 {
4724 return Ok(());
4725 }
4726
4727 for (input, is_directory) in resolved_inputs {
4728 if shared_target == input
4729 || (is_directory && shared_target.starts_with(&input))
4730 || input.starts_with(&shared_target)
4731 {
4732 return Err(WasmBuildError::InvalidSpec {
4733 message: format!(
4734 "shared incremental target {} must not overlap exact Cargo inputs unless it is inside a generated target directory",
4735 shared_target.display()
4736 ),
4737 });
4738 }
4739 }
4740 Ok(())
4741}
4742
4743pub(super) fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
4744 let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
4745 return None;
4746 };
4747 Some(if target_dir.is_absolute() {
4748 target_dir.clone()
4749 } else {
4750 spec.workspace_root.join(target_dir)
4751 })
4752}
4753
4754fn shared_incremental_target_exists(
4755 spec: &WasmBuildSpec,
4756 operation: &'static str,
4757) -> Result<bool, WasmBuildError> {
4758 let target_dir =
4759 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
4760 message: "shared incremental target is not configured".to_owned(),
4761 })?;
4762 match fs::symlink_metadata(&target_dir) {
4763 Ok(metadata) if metadata.is_dir() => Ok(true),
4764 Ok(_) => Err(WasmBuildError::InvalidSpec {
4765 message: format!(
4766 "shared incremental Cargo target {} must be a directory",
4767 target_dir.display()
4768 ),
4769 }),
4770 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
4771 Err(source) => Err(WasmBuildError::Io {
4772 operation,
4773 path: target_dir,
4774 source,
4775 }),
4776 }
4777}
4778
4779fn effective_environment(spec: &WasmBuildSpec) -> BTreeMap<OsString, Option<OsString>> {
4780 let mut names = spec.inherited_env.clone();
4781 names.extend(AUTOMATIC_ENVIRONMENT.iter().map(OsString::from));
4782 let mut environment = names
4783 .into_iter()
4784 .map(|name| {
4785 let value = std::env::var_os(&name);
4786 (name, value)
4787 })
4788 .collect::<BTreeMap<_, _>>();
4789 for (key, value) in &spec.extra_env {
4790 environment.insert(key.clone(), Some(value.clone()));
4791 }
4792 environment
4793}
4794
4795fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
4796 for (key, value) in &spec.extra_env {
4797 command.env(key, value);
4798 }
4799}
4800
4801fn run_cargo_build(
4802 spec: &WasmBuildSpec,
4803 build_target_dir: &Path,
4804 progress: &mut ProgressReporter<'_>,
4805) -> Result<(), WasmBuildError> {
4806 let command = cargo_build_command(spec, build_target_dir)?;
4807 communicate_cargo_build(spec, build_target_dir, command, progress)
4808}
4809
4810fn cargo_build_command(
4811 spec: &WasmBuildSpec,
4812 build_target_dir: &Path,
4813) -> Result<Command, WasmBuildError> {
4814 let absolute_target_dir =
4815 canonicalize_allow_missing(build_target_dir).map_err(|source| WasmBuildError::Io {
4816 operation: "resolve Cargo build target directory",
4817 path: build_target_dir.to_owned(),
4818 source,
4819 })?;
4820 let mut command = Command::new(&spec.cargo_program);
4821 command
4822 .current_dir(&spec.workspace_root)
4823 .env("CARGO_TARGET_DIR", absolute_target_dir)
4824 .arg("build");
4825 if let Some(example) = &spec.example {
4826 command.args(["--example", example]);
4827 } else {
4828 command.arg("--lib");
4829 }
4830 command.args(["--target", &spec.target]).args(
4831 spec.cargo_selection
4832 .arguments
4833 .iter()
4834 .filter(|argument| argument.as_os_str() != OsStr::new("--lib")),
4835 );
4836 apply_command_environment(&mut command, spec);
4837 for package in &spec.packages {
4838 command.args(["-p", package]);
4839 }
4840
4841 Ok(command)
4842}
4843
4844fn communicate_cargo_build(
4845 spec: &WasmBuildSpec,
4846 build_target_dir: &Path,
4847 mut command: Command,
4848 progress: &mut ProgressReporter<'_>,
4849) -> Result<(), WasmBuildError> {
4850 command.stdout(Stdio::piped()).stderr(Stdio::piped());
4853 let started = Instant::now();
4854 let observed = progress.is_observed();
4855 if !observed {
4856 command.stdin(Stdio::null());
4858 }
4859 let mut child = if observed {
4860 OwnedChild::spawn(&mut command)
4861 } else {
4862 OwnedChild::spawn_direct(&mut command)
4863 }
4864 .map_err(|source| WasmBuildError::CommandSpawn {
4865 phase: WasmBuildPhase::CargoBuild,
4866 program: spec.cargo_program.clone(),
4867 source,
4868 })?;
4869 progress.emit(WasmBuildProgressEvent::CargoStarted {
4870 target_dir: build_target_dir.to_owned(),
4871 });
4872 let progress = RefCell::new(progress);
4873 let result = communicate_child_with_observer(
4874 &mut child,
4875 None,
4876 OutputLimits {
4877 stdout: OutputLimit::Truncate(CARGO_DIAGNOSTIC_BYTES),
4880 stderr: OutputLimit::Truncate(CARGO_DIAGNOSTIC_BYTES),
4881 timeout: None,
4882 },
4883 SuccessfulExit::Cleanup,
4884 || {
4885 progress
4886 .borrow_mut()
4887 .emit_heartbeat_if_due(WasmBuildProgressPhase::CargoBuild, started.elapsed());
4888 false
4889 },
4890 |stream, bytes| {
4891 let mut progress = progress.borrow_mut();
4892 if progress.config.emit_cargo_output {
4893 progress.emit(WasmBuildProgressEvent::CargoOutput {
4894 stream: match stream {
4895 OutputStream::Stdout => WasmBuildOutputStream::Stdout,
4896 OutputStream::Stderr => WasmBuildOutputStream::Stderr,
4897 },
4898 bytes: bytes.to_vec(),
4899 });
4900 }
4901 Ok(())
4902 },
4903 );
4904 let status = match &result {
4905 Ok(evidence) => Some(evidence.status),
4906 Err(error) => error.evidence().and_then(|evidence| evidence.status),
4907 };
4908 if let Some(status) = status {
4909 progress
4910 .borrow_mut()
4911 .emit(WasmBuildProgressEvent::CargoFinished {
4912 success: status.success(),
4913 code: status.code(),
4914 elapsed: started.elapsed(),
4915 });
4916 }
4917 let evidence = match result {
4918 Ok(evidence) => evidence,
4919 Err(error) => {
4920 return Err(WasmBuildError::Io {
4921 operation: "communicate with cargo build",
4922 path: PathBuf::from(&spec.cargo_program),
4923 source: io::Error::other(error),
4925 });
4926 }
4927 };
4928 let status = evidence.status;
4929 if status.success() {
4930 return Ok(());
4931 }
4932 Err(WasmBuildError::CommandFailed {
4933 phase: WasmBuildPhase::CargoBuild,
4934 status,
4935 stdout: cargo_diagnostic(&evidence.stdout, evidence.stdout_truncated),
4936 stderr: cargo_diagnostic(&evidence.stderr, evidence.stderr_truncated),
4937 })
4938}
4939
4940fn cargo_diagnostic(bytes: &[u8], truncated: bool) -> String {
4941 let mut text = String::from_utf8_lossy(bytes).into_owned();
4942 if truncated {
4943 text.push_str("\n[diagnostic truncated after 1 MiB; enable Cargo output events for the complete stream]\n");
4944 }
4945 text
4946}
4947
4948fn ensure_command_success(
4949 phase: WasmBuildPhase,
4950 output: CompletedExecution,
4951) -> Result<CompletedExecution, WasmBuildError> {
4952 if output.status.success() {
4953 return Ok(output);
4954 }
4955 Err(WasmBuildError::CommandFailed {
4956 phase,
4957 status: output.status,
4958 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
4959 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
4960 })
4961}
4962
4963fn default_library_name(package: &str) -> String {
4964 package.replace('-', "_")
4965}
4966
4967fn expected_artifacts(spec: &WasmBuildSpec, target_dir: &Path) -> Vec<PathBuf> {
4968 let profile = target_dir.join(&spec.target).join(
4969 spec.cargo_selection
4970 .profile_target_dir()
4971 .expect("Cargo profile was validated before artifact discovery"),
4972 );
4973 spec.packages
4974 .iter()
4975 .map(|package| {
4976 spec.example.as_ref().map_or_else(
4977 || profile.join(format!("{}.wasm", default_library_name(package))),
4978 |example| {
4979 profile
4980 .join("examples")
4981 .join(format!("{}.wasm", default_library_name(example)))
4982 },
4983 )
4984 })
4985 .collect()
4986}
4987
4988fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
4989 spec.target_dir
4990 .join(".ic-testkit/wasm-targets")
4991 .join(fingerprint.to_hex())
4992}
4993
4994fn validated_artifact_set(
4995 artifacts: &[PathBuf],
4996 fingerprint: InputDigest,
4997) -> Option<Vec<FileDigest>> {
4998 let header = artifact_stamp_header(fingerprint);
4999 let stamp_len = artifact_stamp_contents(fingerprint, fingerprint).len();
5002 artifacts
5003 .iter()
5004 .map(|artifact| {
5005 let metadata = fs::metadata(artifact).ok()?;
5006 if !metadata.is_file() || metadata.len() == 0 {
5007 return None;
5008 }
5009 let stamp = read_stamp_with_limit(&artifact_stamp_path(artifact), stamp_len).ok()??;
5010 if stamp.len() != stamp_len || !stamp.starts_with(&header) {
5013 return None;
5014 }
5015 let info = digest_file("wasm-artifact-v1", artifact).ok()?;
5016 (stamp == artifact_stamp_contents(fingerprint, info.digest)).then_some(info)
5017 })
5018 .collect()
5019}
5020
5021fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
5022 artifacts
5023 .iter()
5024 .filter(|path| {
5025 fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
5026 })
5027 .cloned()
5028 .collect()
5029}
5030
5031fn artifact_stamp_path(artifact: &Path) -> PathBuf {
5032 let mut name = artifact
5033 .file_name()
5034 .map_or_else(|| OsString::from("artifact"), OsString::from);
5035 name.push(".ic-testkit-build");
5036 artifact.with_file_name(name)
5037}
5038
5039fn artifact_stamp_header(fingerprint: InputDigest) -> String {
5040 format!("{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\n")
5041}
5042
5043fn artifact_stamp_contents(fingerprint: InputDigest, artifact_digest: InputDigest) -> String {
5044 format!(
5045 "{}artifact-sha256:{artifact_digest}\n",
5046 artifact_stamp_header(fingerprint),
5047 )
5048}
5049
5050fn write_artifact_stamp(
5051 artifact: &Path,
5052 fingerprint: InputDigest,
5053 info: &FileDigest,
5054 preserve_matching: bool,
5055) -> Result<(), WasmBuildError> {
5056 let stamp_path = artifact_stamp_path(artifact);
5057 let stamp = artifact_stamp_contents(fingerprint, info.digest);
5058 if preserve_matching && destination_matches_bytes(&stamp_path, stamp.as_bytes()) {
5059 return Ok(());
5060 }
5061 ic_host_fs::durable::write_bytes(&stamp_path, stamp.as_bytes()).map_err(|source| {
5062 WasmBuildError::Io {
5063 operation: "publish Wasm build stamp",
5064 path: stamp_path,
5065 source: io::Error::other(source),
5066 }
5067 })
5068}
5069
5070fn publish_artifact_stamps(
5071 artifacts: &[PathBuf],
5072 fingerprint: InputDigest,
5073) -> Result<Vec<FileDigest>, WasmBuildError> {
5074 let mut info = Vec::with_capacity(artifacts.len());
5075 for artifact in artifacts {
5076 let digest =
5077 digest_file("wasm-artifact-v1", artifact).map_err(|source| WasmBuildError::Io {
5078 operation: "hash built Wasm artifact",
5079 path: artifact.clone(),
5080 source,
5081 })?;
5082 write_artifact_stamp(artifact, fingerprint, &digest, false)?;
5083 info.push(digest);
5084 }
5085 Ok(info)
5086}
5087
5088fn materialize_artifacts(
5089 cached_artifacts: &[PathBuf],
5090 artifacts: &[PathBuf],
5091 fingerprint: InputDigest,
5092 artifact_info: &[FileDigest],
5093 preserve_matching: bool,
5094) -> Result<(), WasmBuildError> {
5095 for ((cached, artifact), info) in cached_artifacts.iter().zip(artifacts).zip(artifact_info) {
5096 materialize_verified_output(
5097 "wasm-artifact-v1",
5098 cached,
5099 artifact,
5100 preserve_matching.then_some(info),
5101 )
5102 .map_err(|source| WasmBuildError::Io {
5103 operation: "publish Wasm artifact",
5104 path: artifact.clone(),
5105 source,
5106 })?;
5107 }
5108 for (artifact, info) in artifacts.iter().zip(artifact_info) {
5111 write_artifact_stamp(artifact, fingerprint, info, preserve_matching)?;
5112 }
5113 Ok(())
5114}
5115
5116fn copy_wasm_artifacts(
5117 source_artifacts: &[PathBuf],
5118 cached_artifacts: &[PathBuf],
5119) -> Result<(), WasmBuildError> {
5120 for (source, cached) in source_artifacts.iter().zip(cached_artifacts) {
5121 copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
5122 operation: "cache shared-incremental Wasm artifact",
5123 path: cached.clone(),
5124 source: source_error,
5125 })?;
5126 }
5127 Ok(())
5128}
5129
5130fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
5131 fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
5132 operation,
5133 path: path.to_owned(),
5134 source,
5135 })
5136}
5137
5138fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
5139 if path.exists() {
5140 inputs.push((PathBuf::from(label), path));
5141 }
5142}
5143
5144fn invalid_metadata(message: &str) -> WasmBuildError {
5145 WasmBuildError::InvalidMetadata {
5146 message: message.to_owned(),
5147 }
5148}
5149
5150impl WasmBuildError {
5151 fn indicates_input_change(&self) -> bool {
5152 match self {
5153 Self::InputsChangedDuringAcquisition { .. } => true,
5154 Self::FailedBuildCleanup { build_error, .. } => build_error.indicates_input_change(),
5155 _ => false,
5156 }
5157 }
5158}
5159
5160impl std::fmt::Display for WasmBuildPhase {
5161 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5162 formatter.write_str(match self {
5163 Self::CargoMetadata => "cargo metadata",
5164 Self::CargoIdentity => "Cargo identity",
5165 Self::RustcIdentity => "Rust compiler identity",
5166 Self::CargoBuild => "cargo build",
5167 })
5168 }
5169}
5170
5171impl std::fmt::Display for WasmBuildProgressPhase {
5172 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5173 formatter.write_str(match self {
5174 Self::ExactCacheLock => "exact cache lock",
5175 Self::CargoIdentity => "Cargo identity",
5176 Self::RustcIdentity => "Rust compiler identity",
5177 Self::CargoMetadata => "Cargo metadata",
5178 Self::InputDiscovery => "input discovery",
5179 Self::ContentHashing => "content hashing",
5180 Self::SharedTargetLock => "shared target lock",
5181 Self::SharedTargetMaintenance => "shared target maintenance",
5182 Self::CargoBuild => "Cargo build",
5183 Self::ArtifactPublication => "artifact publication",
5184 Self::ExactCacheMaintenance => "exact cache maintenance",
5185 })
5186 }
5187}
5188
5189impl std::fmt::Display for WasmBuildError {
5190 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5191 match self {
5192 Self::InvalidSpec { message } => {
5193 write!(formatter, "invalid Wasm build spec: {message}")
5194 }
5195 Self::Io {
5196 operation,
5197 path,
5198 source,
5199 } => write!(
5200 formatter,
5201 "failed to {operation} at {}: {source}",
5202 path.display()
5203 ),
5204 Self::CommandSpawn {
5205 phase,
5206 program,
5207 source,
5208 } => write!(
5209 formatter,
5210 "failed to launch {phase} using `{}`: {source}",
5211 program.to_string_lossy(),
5212 ),
5213 Self::CommandFailed {
5214 phase,
5215 status,
5216 stdout,
5217 stderr,
5218 } => write!(
5219 formatter,
5220 "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
5221 ),
5222 Self::InvalidMetadata { message } => {
5223 write!(formatter, "invalid Cargo metadata: {message}")
5224 }
5225 Self::InvalidCargoConfiguration { path, message } => write!(
5226 formatter,
5227 "invalid Cargo configuration at {}: {message}",
5228 path.display(),
5229 ),
5230 Self::MissingArtifacts { paths } => write!(
5231 formatter,
5232 "cargo build succeeded without producing: {}",
5233 paths
5234 .iter()
5235 .map(|path| path.display().to_string())
5236 .collect::<Vec<_>>()
5237 .join(", "),
5238 ),
5239 Self::InputsChangedDuringAcquisition { before, after } => write!(
5240 formatter,
5241 "Wasm inputs changed during artifact acquisition: {before} -> {after}",
5242 ),
5243 Self::PreparedInputSnapshotInvalidated => formatter.write_str(
5244 "the prepared Wasm input snapshot was invalidated before artifact publication",
5245 ),
5246 Self::FailedBuildCleanup {
5247 build_error,
5248 path,
5249 source,
5250 } => write!(
5251 formatter,
5252 "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
5253 path.display(),
5254 ),
5255 }
5256 }
5257}
5258
5259impl std::error::Error for WasmBuildError {
5260 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
5261 match self {
5262 Self::Io { source, .. }
5263 | Self::CommandSpawn { source, .. }
5264 | Self::FailedBuildCleanup { source, .. } => Some(source),
5265 _ => None,
5266 }
5267 }
5268}
5269
5270#[cfg(test)]
5271mod tests;