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