1use ic_host_process::{
2 child::OwnedChild,
3 tool::{
4 CompletedExecution, ExecutionFailure, ExecutionOperation, OutputLimit, OutputLimits,
5 OutputStream, SuccessfulExit, ToolError, capture_command, communicate_child_with_observer,
6 },
7};
8use serde_json::Value;
9use std::{
10 cell::RefCell,
11 collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
12 ffi::{OsStr, OsString},
13 fs::{self, File},
14 io,
15 path::{Component, Path, PathBuf},
16 process::{Command, ExitStatus, Stdio},
17 sync::{
18 Arc, RwLock,
19 atomic::{AtomicUsize, Ordering},
20 mpsc::{self, RecvTimeoutError},
21 },
22 thread,
23 time::{Duration, Instant, SystemTime},
24};
25use toml::Value as TomlValue;
26
27use crate::timing::saturating_add_optional_duration;
28
29use super::{
30 cache_fs::{
31 ArtifactCacheMaintenance, ArtifactCachePrunePolicy, ArtifactCachePruneReport, CacheFsError,
32 RetainedCacheEntry, cache_entry_last_used, cache_maintenance_due,
33 canonicalize_allow_missing, directory_logical_size,
34 ensure_cache_directory_tag as ensure_cache_tag, is_sha256_directory, lock_cache_file,
35 lock_cache_file_with_wait_observer, perform_scheduled_cache_maintenance,
36 prune_direct_child_directories, record_cache_entry_use as record_entry_use,
37 record_cache_maintenance, remove_path_if_present, remove_unretained_entry,
38 },
39 digest::{
40 FileDigest, InputDigest, InputHasher, LabeledPathDigestCache, copy_file_atomic,
41 destination_matches_bytes, destination_matches_digest, digest_bytes, digest_file,
42 digest_labeled_paths_composable, os_bytes, read_stamp_with_limit,
43 },
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) const 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: saturating_add_optional_duration(
1297 self.shared_incremental_lock_wait,
1298 other.shared_incremental_lock_wait,
1299 ),
1300 input_resolution,
1301 cargo_build: saturating_add_optional_duration(self.cargo_build, other.cargo_build),
1302 cache_maintenance: saturating_add_optional_duration(
1303 self.cache_maintenance,
1304 other.cache_maintenance,
1305 ),
1306 total: self.total.saturating_add(other.total),
1307 }
1308 }
1309}
1310
1311impl WasmInputResolutionTimings {
1312 #[must_use]
1314 pub const fn tool_identity(self) -> Duration {
1315 self.tool_identity
1316 }
1317
1318 #[must_use]
1320 pub const fn cargo_metadata(self) -> Duration {
1321 self.cargo_metadata
1322 }
1323
1324 #[must_use]
1326 pub const fn input_discovery(self) -> Duration {
1327 self.input_discovery
1328 }
1329
1330 #[must_use]
1332 pub const fn content_hashing(self) -> Duration {
1333 self.content_hashing
1334 }
1335
1336 #[must_use]
1338 pub const fn total(self) -> Duration {
1339 self.total
1340 }
1341
1342 const fn include(&mut self, other: Self) {
1343 self.tool_identity = self.tool_identity.saturating_add(other.tool_identity);
1344 self.cargo_metadata = self.cargo_metadata.saturating_add(other.cargo_metadata);
1345 self.input_discovery = self.input_discovery.saturating_add(other.input_discovery);
1346 self.content_hashing = self.content_hashing.saturating_add(other.content_hashing);
1347 self.total = self.total.saturating_add(other.total);
1348 }
1349}
1350
1351impl WasmBuildFailureDetails {
1352 pub(super) fn specification(total: Duration) -> Self {
1353 Self {
1354 phase: WasmBuildFailurePhase::Specification,
1355 timings: WasmBuildFailureTimings {
1356 total,
1357 ..WasmBuildFailureTimings::default()
1358 },
1359 }
1360 }
1361
1362 #[must_use]
1364 pub const fn phase(self) -> WasmBuildFailurePhase {
1365 self.phase
1366 }
1367
1368 #[must_use]
1370 pub const fn timings(self) -> WasmBuildFailureTimings {
1371 self.timings
1372 }
1373}
1374
1375impl WasmBuildFailureTimings {
1376 #[must_use]
1378 pub const fn exact_cache_coordination(self) -> Duration {
1379 self.exact_cache_coordination
1380 }
1381
1382 #[must_use]
1384 pub const fn shared_target_coordination(self) -> Option<Duration> {
1385 self.shared_target_coordination
1386 }
1387
1388 #[must_use]
1390 pub const fn input_resolution(self) -> WasmInputResolutionTimings {
1391 self.input_resolution
1392 }
1393
1394 #[must_use]
1396 pub const fn shared_target_maintenance(self) -> Option<Duration> {
1397 self.shared_target_maintenance
1398 }
1399
1400 #[must_use]
1402 pub const fn cargo_build(self) -> Option<Duration> {
1403 self.cargo_build
1404 }
1405
1406 #[must_use]
1408 pub const fn artifact_publication(self) -> Option<Duration> {
1409 self.artifact_publication
1410 }
1411
1412 #[must_use]
1414 pub const fn exact_cache_maintenance(self) -> Option<Duration> {
1415 self.exact_cache_maintenance
1416 }
1417
1418 #[must_use]
1420 pub const fn cleanup(self) -> Option<Duration> {
1421 self.cleanup
1422 }
1423
1424 #[must_use]
1426 pub const fn total(self) -> Duration {
1427 self.total
1428 }
1429}
1430
1431impl CargoBuildInput {
1432 #[must_use]
1434 pub fn label(&self) -> &Path {
1435 &self.label
1436 }
1437
1438 #[must_use]
1443 pub fn path(&self) -> &Path {
1444 &self.path
1445 }
1446}
1447
1448impl ResolvedCargoBuildInputs {
1449 pub fn artifact_paths(&self) -> Result<&[PathBuf], WasmBuildError> {
1459 if let Some(message) = &self.wasm_artifact_error {
1460 return Err(WasmBuildError::InvalidSpec {
1461 message: message.clone(),
1462 });
1463 }
1464 Ok(&self.artifact_paths)
1465 }
1466
1467 #[must_use]
1469 pub const fn fingerprint(&self) -> InputDigest {
1470 self.fingerprint
1471 }
1472
1473 #[must_use]
1478 pub const fn input_digest(&self) -> InputDigest {
1479 self.input_digest
1480 }
1481
1482 #[must_use]
1487 pub const fn validation_digest(&self) -> InputDigest {
1488 self.validation_digest
1489 }
1490
1491 #[must_use]
1493 pub fn inputs(&self) -> &[CargoBuildInput] {
1494 &self.inputs
1495 }
1496
1497 #[must_use]
1502 pub fn exclusions(&self) -> &[PathBuf] {
1503 &self.exclusions
1504 }
1505
1506 #[must_use]
1508 pub const fn timings(&self) -> WasmInputResolutionTimings {
1509 self.timings
1510 }
1511
1512 pub fn is_current(&self, spec: &WasmBuildSpec) -> Result<bool, WasmBuildError> {
1517 resolve_cargo_build_inputs(spec).map(|current| current.fingerprint == self.fingerprint)
1518 }
1519
1520 pub fn is_content_current(&self) -> Result<bool, WasmBuildError> {
1530 self.current_validation_digest()
1531 .map(|current| current == self.validation_digest)
1532 }
1533
1534 pub(super) fn current_validation_digest(&self) -> Result<InputDigest, WasmBuildError> {
1535 digest_labeled_paths_composable(
1536 "wasm-source-inputs-v1",
1537 self.inputs
1538 .iter()
1539 .map(|input| (input.label.as_path(), input.path.as_path())),
1540 &self.exclusions,
1541 &mut LabeledPathDigestCache::default(),
1542 )
1543 .map_err(|source| WasmBuildError::Io {
1544 operation: "rehash resolved Cargo build inputs",
1545 path: self
1546 .inputs
1547 .first()
1548 .map_or_else(PathBuf::new, |input| input.path.clone()),
1549 source,
1550 })
1551 }
1552}
1553
1554impl WasmBuildSessionState {
1555 pub(super) fn new() -> Self {
1556 Self {
1557 snapshots: Vec::new(),
1558 digest_cache: LabeledPathDigestCache::default(),
1559 snapshot_reuses: 0,
1560 invalidated: false,
1561 }
1562 }
1563
1564 pub(super) const fn snapshot_count(&self) -> usize {
1565 self.snapshots.len()
1566 }
1567
1568 pub(super) const fn snapshot_reuses(&self) -> usize {
1569 self.snapshot_reuses
1570 }
1571
1572 pub(super) const fn is_invalidated(&self) -> bool {
1573 self.invalidated
1574 }
1575
1576 fn reuse(&mut self, spec: &WasmBuildSpec) -> Option<ResolvedCargoBuildInputs> {
1577 let (_, snapshot) = self
1578 .snapshots
1579 .iter()
1580 .find(|(candidate, _)| candidate == spec)?;
1581 self.snapshot_reuses = self.snapshot_reuses.saturating_add(1);
1582 Some(snapshot.clone())
1583 }
1584
1585 fn remember(&mut self, spec: &WasmBuildSpec, resolved: &ResolvedCargoBuildInputs) {
1586 if self
1587 .snapshots
1588 .iter()
1589 .any(|(candidate, _)| candidate == spec)
1590 {
1591 return;
1592 }
1593 let mut snapshot = resolved.clone();
1594 snapshot.timings = WasmInputResolutionTimings::default();
1595 self.snapshots.push((spec.clone(), snapshot));
1596 }
1597
1598 fn invalidate(&mut self) {
1599 self.snapshots.clear();
1600 self.digest_cache = LabeledPathDigestCache::default();
1601 self.invalidated = true;
1602 }
1603}
1604
1605impl WasmBuildInputSnapshotState {
1606 pub(super) fn prepare(specs: &[WasmBuildSpec]) -> Result<Self, WasmBuildError> {
1607 for spec in specs {
1608 validate_spec(spec)?;
1609 }
1610 let mut resolver = WasmBuildBatchInputResolver::new(specs, None);
1611 let mut snapshots = Vec::with_capacity(specs.len());
1612 let mut preparation_timings = WasmInputResolutionTimings::default();
1613 let mut progress = ProgressReporter::silent();
1614 for (index, spec) in specs.iter().enumerate() {
1615 let mut prepared_input = resolver.resolve(index, &mut progress)?;
1616 preparation_timings.include(prepared_input.timings);
1617 prepared_input.timings = WasmInputResolutionTimings::default();
1618 snapshots.push((spec.clone(), prepared_input));
1619 }
1620 Ok(Self {
1621 snapshots,
1622 preparation_metrics: resolver.metrics(),
1623 preparation_timings,
1624 reader_reuses: AtomicUsize::new(0),
1625 invalidation: Arc::new(RwLock::new(false)),
1626 })
1627 }
1628
1629 pub(super) fn contains(&self, spec: &WasmBuildSpec) -> bool {
1630 self.snapshots
1631 .iter()
1632 .any(|(candidate, _)| candidate == spec)
1633 }
1634
1635 fn reuse(&self, spec: &WasmBuildSpec) -> Option<ResolvedCargoBuildInputs> {
1636 let (_, snapshot) = self
1637 .snapshots
1638 .iter()
1639 .find(|(candidate, _)| candidate == spec)?;
1640 let mut current = self.reader_reuses.load(Ordering::Relaxed);
1641 loop {
1642 match self.reader_reuses.compare_exchange_weak(
1643 current,
1644 current.saturating_add(1),
1645 Ordering::Relaxed,
1646 Ordering::Relaxed,
1647 ) {
1648 Ok(_) => break,
1649 Err(observed) => current = observed,
1650 }
1651 }
1652 Some(snapshot.clone())
1653 }
1654
1655 pub(super) const fn specification_count(&self) -> usize {
1656 self.snapshots.len()
1657 }
1658
1659 pub(super) const fn preparation_metrics(&self) -> WasmBuildBatchInputMetrics {
1660 self.preparation_metrics
1661 }
1662
1663 pub(super) const fn preparation_timings(&self) -> WasmInputResolutionTimings {
1664 self.preparation_timings
1665 }
1666
1667 pub(super) fn reader_reuses(&self) -> usize {
1668 self.reader_reuses.load(Ordering::Relaxed)
1669 }
1670
1671 pub(super) fn is_invalidated(&self) -> bool {
1672 *self
1673 .invalidation
1674 .read()
1675 .unwrap_or_else(std::sync::PoisonError::into_inner)
1676 }
1677
1678 fn invalidate(&self) {
1679 *self
1680 .invalidation
1681 .write()
1682 .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
1683 }
1684
1685 fn invalidation(&self) -> Arc<RwLock<bool>> {
1686 Arc::clone(&self.invalidation)
1687 }
1688}
1689
1690impl<'a, 'session> WasmBuildBatchInputResolver<'a, 'session> {
1691 pub(super) fn new(
1692 specs: impl IntoIterator<Item = &'a WasmBuildSpec>,
1693 mut reuse: Option<WasmBuildInputReuse<'session>>,
1694 ) -> Self {
1695 let specs = specs.into_iter().collect::<Vec<_>>();
1696 let mut keys = Vec::<BatchResolutionKey>::new();
1697 let mut groups = Vec::<BatchResolutionGroup>::new();
1698 let mut group_by_index = Vec::with_capacity(specs.len());
1699 for (index, spec) in specs.iter().copied().enumerate() {
1700 let key = BatchResolutionKey::for_spec(spec);
1701 let group = keys
1702 .iter()
1703 .position(|candidate| *candidate == key)
1704 .unwrap_or_else(|| {
1705 keys.push(key);
1706 groups.push(BatchResolutionGroup {
1707 indexes: Vec::new(),
1708 });
1709 groups.len() - 1
1710 });
1711 groups[group].indexes.push(index);
1712 group_by_index.push(group);
1713 }
1714 let mut metrics = WasmBuildBatchInputMetrics::default();
1715 let resolved = specs
1716 .iter()
1717 .map(|spec| match reuse.as_mut() {
1718 Some(WasmBuildInputReuse::Session(session)) => {
1719 let reused = session.reuse(spec);
1720 if reused.is_some() {
1721 metrics.session_reuses = metrics.session_reuses.saturating_add(1);
1722 }
1723 reused.map(Ok)
1724 }
1725 Some(WasmBuildInputReuse::Snapshot(snapshot)) => {
1726 let reused = snapshot
1727 .reuse(spec)
1728 .expect("prepared input snapshot must contain every reader specification");
1729 metrics.prepared_reuses = metrics.prepared_reuses.saturating_add(1);
1730 Some(Ok(reused))
1731 }
1732 None => None,
1733 })
1734 .collect();
1735 Self {
1736 specs,
1737 groups,
1738 group_by_index,
1739 resolved,
1740 reuse,
1741 metrics,
1742 }
1743 }
1744
1745 pub(super) const fn metrics(&self) -> WasmBuildBatchInputMetrics {
1746 self.metrics
1747 }
1748
1749 pub(super) fn invalidate_source_lease(&mut self) {
1750 match self.reuse.as_mut() {
1751 Some(WasmBuildInputReuse::Session(session)) => {
1752 session.invalidate();
1753 for resolved in &mut self.resolved {
1757 *resolved = None;
1758 }
1759 self.reuse = None;
1760 }
1761 Some(WasmBuildInputReuse::Snapshot(snapshot)) => snapshot.invalidate(),
1762 None => {}
1763 }
1764 }
1765
1766 pub(super) const fn assumes_sources_immutable(&self) -> bool {
1767 self.reuse.is_some()
1768 }
1769
1770 pub(super) fn prepared_invalidation(&self) -> Option<Arc<RwLock<bool>>> {
1771 match self.reuse.as_ref() {
1772 Some(WasmBuildInputReuse::Snapshot(snapshot)) => Some(snapshot.invalidation()),
1773 _ => None,
1774 }
1775 }
1776
1777 fn resolve(
1778 &mut self,
1779 index: usize,
1780 progress: &mut ProgressReporter<'_>,
1781 ) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1782 if self.resolved[index].is_none() {
1783 self.resolve_group(index, progress)?;
1784 }
1785 self.resolved[index]
1786 .take()
1787 .expect("resolved batch input must be populated")
1788 .map_err(|(phase, error)| {
1789 progress.begin_phase(phase);
1790 error
1791 })
1792 }
1793
1794 fn resolve_group(
1795 &mut self,
1796 active_index: usize,
1797 progress: &mut ProgressReporter<'_>,
1798 ) -> Result<(), WasmBuildError> {
1799 let total_started = Instant::now();
1800 let group = self.group_by_index[active_index];
1801 let active = self.specs[active_index];
1802
1803 let (cargo_identity, rustc_identity, tool_identity) =
1804 resolve_tool_identity(active, progress)?;
1805
1806 let metadata_started = Instant::now();
1807 let metadata = progress.run_phase(WasmBuildProgressPhase::CargoMetadata, || {
1808 cargo_metadata(active)
1809 })?;
1810 let cargo_metadata = metadata_started.elapsed();
1811
1812 let (discovered, input_discovery) = self.discover_group_inputs(group, &metadata, progress);
1813
1814 let hashing_started = Instant::now();
1815 let mut batch_digest_cache = LabeledPathDigestCache::default();
1816 let digest_cache = match self.reuse.as_mut() {
1817 Some(WasmBuildInputReuse::Session(session)) => &mut session.digest_cache,
1818 _ => &mut batch_digest_cache,
1819 };
1820 let workspace_root = &active.workspace_root;
1821 let resolved_inputs = progress.run_phase(WasmBuildProgressPhase::ContentHashing, || {
1822 discovered
1823 .into_iter()
1824 .map(|(index, inputs, exclusions)| {
1825 let result = digest_resolved_local_inputs(
1826 &inputs,
1827 &exclusions,
1828 digest_cache,
1829 workspace_root,
1830 "hash batched Wasm build inputs",
1831 "hash batched semantic Wasm build inputs",
1832 )
1833 .map(|(input_digest, validation_digest)| {
1834 (
1835 inputs.validation_inputs,
1836 exclusions,
1837 input_digest,
1838 validation_digest,
1839 inputs.wasm_artifact_error,
1840 )
1841 });
1842 (index, result)
1843 })
1844 .collect::<Vec<_>>()
1845 });
1846 let content_hashing = hashing_started.elapsed();
1847 let timings = WasmInputResolutionTimings {
1848 tool_identity,
1849 cargo_metadata,
1850 input_discovery,
1851 content_hashing,
1852 total: total_started.elapsed(),
1853 };
1854 let resolved_count = resolved_inputs
1855 .iter()
1856 .filter(|(_, result)| result.is_ok())
1857 .count();
1858 self.metrics.runs += usize::from(resolved_count > 0);
1859 self.metrics.reuses += resolved_count.saturating_sub(1);
1860 let timing_index = resolved_inputs
1861 .iter()
1862 .find(|(index, result)| *index == active_index && result.is_ok())
1863 .or_else(|| resolved_inputs.iter().find(|(_, result)| result.is_ok()))
1864 .map(|(index, _)| *index);
1865 for (index, result) in resolved_inputs {
1866 let (inputs, exclusions, input_digest, validation_digest, wasm_artifact_error) =
1867 match result {
1868 Ok(resolved) => resolved,
1869 Err(error) => {
1870 self.resolved[index] =
1871 Some(Err((WasmBuildFailurePhase::ContentHashing, error)));
1872 continue;
1873 }
1874 };
1875 let spec = self.specs[index];
1876 let resolved = ResolvedCargoBuildInputs {
1877 fingerprint: finish_build_fingerprint(
1878 spec,
1879 &cargo_identity,
1880 &rustc_identity,
1881 input_digest,
1882 ),
1883 input_digest,
1884 validation_digest,
1885 inputs: cargo_build_inputs(inputs),
1886 exclusions: exclusions.into(),
1887 wasm_artifact_error,
1888 artifact_paths: expected_artifacts(spec, &spec.target_dir).into(),
1889 timings: if Some(index) == timing_index {
1890 timings
1891 } else {
1892 WasmInputResolutionTimings::default()
1893 },
1894 };
1895 if let Some(WasmBuildInputReuse::Session(session)) = self.reuse.as_mut() {
1896 session.remember(spec, &resolved);
1897 }
1898 self.resolved[index] = Some(Ok(resolved));
1899 }
1900 Ok(())
1901 }
1902
1903 fn discover_group_inputs(
1904 &mut self,
1905 group: usize,
1906 metadata: &Value,
1907 progress: &mut ProgressReporter<'_>,
1908 ) -> (Vec<(usize, ResolvedLocalInputs, Vec<PathBuf>)>, Duration) {
1909 let started = Instant::now();
1910 let pending = self.groups[group].indexes.iter().copied().filter(|index| {
1911 self.resolved[*index].is_none() && validate_spec(self.specs[*index]).is_ok()
1912 });
1913 let results = progress.run_phase(WasmBuildProgressPhase::InputDiscovery, || {
1914 let parsed = ParsedCargoMetadata::parse(metadata);
1915 pending
1916 .map(|index| {
1917 let spec = self.specs[index];
1918 let result = (|| {
1919 let parsed = parsed
1920 .as_ref()
1921 .map_err(|message| invalid_metadata(message))?;
1922 resolve_local_inputs(spec, parsed)
1923 })();
1924 (index, result)
1925 })
1926 .collect::<Vec<_>>()
1927 });
1928 let mut discovered = Vec::new();
1929 for (index, result) in results {
1930 match result {
1931 Ok((inputs, exclusions)) => discovered.push((index, inputs, exclusions)),
1932 Err(error) => {
1933 self.resolved[index] =
1934 Some(Err((WasmBuildFailurePhase::InputDiscovery, error)));
1935 }
1936 }
1937 }
1938 (discovered, started.elapsed())
1939 }
1940}
1941
1942fn resolve_tool_identity(
1943 spec: &WasmBuildSpec,
1944 progress: &mut ProgressReporter<'_>,
1945) -> Result<(Vec<u8>, Vec<u8>, Duration), WasmBuildError> {
1946 let started = Instant::now();
1947 let cargo_identity = progress.run_phase(WasmBuildProgressPhase::CargoIdentity, || {
1948 command_identity(
1949 spec,
1950 WasmBuildPhase::CargoIdentity,
1951 &spec.cargo_program,
1952 &["--version", "--verbose"],
1953 )
1954 })?;
1955 let rustc_program = spec
1956 .extra_env
1957 .get(OsStr::new("RUSTC"))
1958 .unwrap_or(&spec.rustc_program);
1959 let rustc_identity = progress.run_phase(WasmBuildProgressPhase::RustcIdentity, || {
1960 command_identity(spec, WasmBuildPhase::RustcIdentity, rustc_program, &["-vV"])
1961 })?;
1962 Ok((cargo_identity, rustc_identity, started.elapsed()))
1963}
1964
1965impl<'a> BatchResolutionKey<'a> {
1966 fn for_spec(spec: &'a WasmBuildSpec) -> Self {
1967 Self {
1968 workspace_root: &spec.workspace_root,
1969 cargo_program: &spec.cargo_program,
1970 rustc_program: spec
1971 .extra_env
1972 .get(OsStr::new("RUSTC"))
1973 .unwrap_or(&spec.rustc_program),
1974 metadata_arguments: metadata_arguments(&spec.cargo_profile_args),
1975 environment: effective_environment(spec),
1976 }
1977 }
1978}
1979
1980impl SharedIncrementalTargetInspection {
1981 #[must_use]
1983 pub fn target_dir(&self) -> &Path {
1984 &self.target_dir
1985 }
1986
1987 #[must_use]
1989 pub const fn logical_size_bytes(&self) -> u64 {
1990 self.logical_size_bytes
1991 }
1992
1993 #[must_use]
1995 pub const fn last_used(&self) -> SystemTime {
1996 self.last_used
1997 }
1998
1999 #[must_use]
2001 pub const fn lock_wait(&self) -> Duration {
2002 self.lock_wait
2003 }
2004}
2005
2006impl SharedIncrementalTargetPrunePolicy {
2007 #[must_use]
2009 pub const fn new() -> Self {
2010 Self {
2011 max_age: None,
2012 max_size_bytes: None,
2013 }
2014 }
2015
2016 #[must_use]
2018 pub const fn with_max_age(mut self, max_age: Duration) -> Self {
2019 self.max_age = Some(max_age);
2020 self
2021 }
2022
2023 #[must_use]
2025 pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
2026 self.max_size_bytes = Some(bytes);
2027 self
2028 }
2029
2030 #[must_use]
2032 pub const fn max_age(self) -> Option<Duration> {
2033 self.max_age
2034 }
2035
2036 #[must_use]
2038 pub const fn max_size_bytes(self) -> Option<u64> {
2039 self.max_size_bytes
2040 }
2041
2042 fn maintenance_identity(self) -> String {
2043 format!(
2044 "age={:?};size={:?}",
2045 self.max_age.map(|duration| duration.as_nanos()),
2046 self.max_size_bytes
2047 )
2048 }
2049}
2050
2051impl SharedIncrementalTargetMaintenanceConfig {
2052 #[must_use]
2054 pub const fn new(
2055 policy: SharedIncrementalTargetPrunePolicy,
2056 minimum_interval: Duration,
2057 ) -> Self {
2058 Self {
2059 policy,
2060 minimum_interval,
2061 failure_mode: SharedIncrementalTargetMaintenanceFailureMode::Strict,
2062 }
2063 }
2064
2065 #[must_use]
2067 pub const fn with_failure_mode(
2068 mut self,
2069 failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
2070 ) -> Self {
2071 self.failure_mode = failure_mode;
2072 self
2073 }
2074
2075 #[must_use]
2077 pub const fn policy(self) -> SharedIncrementalTargetPrunePolicy {
2078 self.policy
2079 }
2080
2081 #[must_use]
2083 pub const fn minimum_interval(self) -> Duration {
2084 self.minimum_interval
2085 }
2086
2087 #[must_use]
2089 pub const fn failure_mode(self) -> SharedIncrementalTargetMaintenanceFailureMode {
2090 self.failure_mode
2091 }
2092}
2093
2094impl SharedIncrementalTargetMaintenance {
2095 #[must_use]
2097 pub fn target_dir(&self) -> &Path {
2098 &self.target_dir
2099 }
2100
2101 #[must_use]
2103 pub const fn logical_size_bytes_before(&self) -> u64 {
2104 self.logical_size_bytes_before
2105 }
2106
2107 #[must_use]
2109 pub const fn logical_size_bytes_after(&self) -> u64 {
2110 self.logical_size_bytes_after
2111 }
2112
2113 #[must_use]
2115 pub const fn last_used_before(&self) -> SystemTime {
2116 self.last_used_before
2117 }
2118
2119 #[must_use]
2121 pub const fn was_cleared(&self) -> bool {
2122 self.cleared
2123 }
2124
2125 #[must_use]
2127 pub const fn lock_wait(&self) -> Duration {
2128 self.lock_wait
2129 }
2130
2131 #[must_use]
2133 pub const fn maintenance(&self) -> Duration {
2134 self.maintenance
2135 }
2136}
2137
2138impl std::fmt::Display for SharedIncrementalTargetMaintenance {
2139 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2140 write!(
2141 formatter,
2142 "target={} action={} bytes={}=>{} lock={:?} maintenance={:?}",
2143 self.target_dir.display(),
2144 if self.cleared { "cleared" } else { "retained" },
2145 self.logical_size_bytes_before,
2146 self.logical_size_bytes_after,
2147 self.lock_wait,
2148 self.maintenance,
2149 )
2150 }
2151}
2152
2153impl SharedIncrementalTargetMaintenanceOutcome {
2154 #[must_use]
2156 pub fn target_dir(&self) -> &Path {
2157 match self {
2158 Self::Missing { target_dir }
2159 | Self::Skipped { target_dir, .. }
2160 | Self::Failed { target_dir, .. } => target_dir,
2161 Self::Performed { maintenance, .. } => maintenance.target_dir(),
2162 }
2163 }
2164
2165 #[must_use]
2167 pub const fn maintenance(&self) -> Option<&SharedIncrementalTargetMaintenance> {
2168 match self {
2169 Self::Performed { maintenance, .. } => Some(maintenance),
2170 Self::Missing { .. } | Self::Skipped { .. } | Self::Failed { .. } => None,
2171 }
2172 }
2173
2174 #[must_use]
2176 pub const fn was_performed(&self) -> bool {
2177 matches!(self, Self::Performed { .. })
2178 }
2179
2180 #[must_use]
2182 pub const fn lock_wait(&self) -> Option<Duration> {
2183 match self {
2184 Self::Missing { .. } => None,
2185 Self::Skipped { lock_wait, .. } | Self::Failed { lock_wait, .. } => Some(*lock_wait),
2186 Self::Performed { maintenance, .. } => Some(maintenance.lock_wait()),
2187 }
2188 }
2189
2190 #[must_use]
2192 pub const fn schedule_check(&self) -> Option<Duration> {
2193 match self {
2194 Self::Missing { .. } | Self::Failed { .. } => None,
2195 Self::Skipped { schedule_check, .. } | Self::Performed { schedule_check, .. } => {
2196 Some(*schedule_check)
2197 }
2198 }
2199 }
2200
2201 #[must_use]
2203 pub fn failure_message(&self) -> Option<&str> {
2204 match self {
2205 Self::Failed { message, .. } => Some(message),
2206 Self::Missing { .. } | Self::Skipped { .. } | Self::Performed { .. } => None,
2207 }
2208 }
2209}
2210
2211impl std::fmt::Display for SharedIncrementalTargetMaintenanceOutcome {
2212 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2213 match self {
2214 Self::Missing { target_dir } => {
2215 write!(formatter, "target={} action=missing", target_dir.display())
2216 }
2217 Self::Skipped {
2218 target_dir,
2219 lock_wait,
2220 schedule_check,
2221 } => write!(
2222 formatter,
2223 "target={} action=skipped lock={lock_wait:?} schedule={schedule_check:?}",
2224 target_dir.display(),
2225 ),
2226 Self::Performed {
2227 maintenance,
2228 schedule_check,
2229 } => write!(formatter, "{maintenance} schedule={schedule_check:?}"),
2230 Self::Failed {
2231 target_dir,
2232 lock_wait,
2233 message,
2234 } => write!(
2235 formatter,
2236 "target={} action=failed lock={lock_wait:?} error={message}",
2237 target_dir.display(),
2238 ),
2239 }
2240 }
2241}
2242
2243impl std::fmt::Display for WasmBuildTimings {
2244 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2245 write!(
2246 formatter,
2247 "total={:?} lock={:?} shared_lock={:?} inputs={:?} cargo={:?} maintenance={:?}",
2248 self.total,
2249 self.lock_wait,
2250 self.shared_incremental_lock_wait,
2251 self.input_resolution.total,
2252 self.cargo_build,
2253 self.cache_maintenance,
2254 )
2255 }
2256}
2257
2258impl std::fmt::Display for WasmBuildOutcome {
2259 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2260 let state = if self.is_reused() { "reused" } else { "built" };
2261 write!(
2262 formatter,
2263 "{state} fingerprint={} artifacts={} {}",
2264 self.record().fingerprint,
2265 self.record().artifacts.len(),
2266 self.record().timings,
2267 )?;
2268 if let Some(maintenance) = self.record().shared_incremental_maintenance() {
2269 write!(formatter, " shared_maintenance=({maintenance})")?;
2270 }
2271 Ok(())
2272 }
2273}
2274
2275pub fn resolve_cargo_build_inputs(
2289 spec: &WasmBuildSpec,
2290) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2291 validate_spec(spec)?;
2292 build_fingerprint(spec)
2293}
2294
2295pub fn inspect_shared_incremental_target(
2303 spec: &WasmBuildSpec,
2304) -> Result<Option<SharedIncrementalTargetInspection>, WasmBuildError> {
2305 if !shared_incremental_target_exists(spec, "inspect shared incremental Cargo target")? {
2306 return Ok(None);
2307 }
2308
2309 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2310 let logical_size_bytes =
2311 directory_logical_size(&canonical).map_err(|source| WasmBuildError::Io {
2312 operation: "measure shared incremental Cargo target",
2313 path: canonical.clone(),
2314 source,
2315 })?;
2316 let last_used = cache_entry_last_used(&canonical).map_err(|source| WasmBuildError::Io {
2317 operation: "read shared incremental Cargo target use time",
2318 path: canonical.clone(),
2319 source,
2320 })?;
2321 Ok(Some(SharedIncrementalTargetInspection {
2322 target_dir: canonical,
2323 logical_size_bytes,
2324 last_used,
2325 lock_wait,
2326 }))
2327}
2328
2329pub fn maintain_shared_incremental_target(
2347 spec: &WasmBuildSpec,
2348 policy: SharedIncrementalTargetPrunePolicy,
2349) -> Result<Option<SharedIncrementalTargetMaintenance>, WasmBuildError> {
2350 if !shared_incremental_target_exists(
2351 spec,
2352 "inspect shared incremental Cargo target before maintenance",
2353 )? {
2354 return Ok(None);
2355 }
2356
2357 let _ = resolve_cargo_build_inputs(spec)?;
2361 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2362 maintain_shared_incremental_target_locked(&canonical, policy, lock_wait).map(Some)
2363}
2364
2365pub fn maintain_shared_incremental_target_at_most_every(
2383 spec: &WasmBuildSpec,
2384 policy: SharedIncrementalTargetPrunePolicy,
2385 minimum_interval: Duration,
2386) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2387 let target_dir =
2388 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
2389 message: "shared incremental target is not configured".to_owned(),
2390 })?;
2391 if !shared_incremental_target_exists(
2392 spec,
2393 "inspect shared incremental Cargo target before scheduled maintenance",
2394 )? {
2395 return Ok(SharedIncrementalTargetMaintenanceOutcome::Missing { target_dir });
2396 }
2397
2398 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2399 let schedule = schedule_shared_incremental_target_maintenance(
2400 &canonical,
2401 policy,
2402 minimum_interval,
2403 lock_wait,
2404 )?;
2405 let schedule = match schedule {
2406 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => return Ok(outcome),
2407 SharedIncrementalTargetMaintenanceSchedule::Due(due) => due,
2408 };
2409
2410 let _ = resolve_cargo_build_inputs(spec)?;
2413 perform_due_shared_incremental_target_maintenance(&canonical, policy, lock_wait, schedule)
2414}
2415
2416enum SharedIncrementalTargetMaintenanceSchedule {
2417 Skipped(SharedIncrementalTargetMaintenanceOutcome),
2418 Due(DueSharedIncrementalTargetMaintenance),
2419}
2420
2421struct DueSharedIncrementalTargetMaintenance {
2422 schedule_root: PathBuf,
2423 maintenance_identity: String,
2424 schedule_check: Duration,
2425}
2426
2427fn schedule_shared_incremental_target_maintenance(
2428 canonical: &Path,
2429 policy: SharedIncrementalTargetPrunePolicy,
2430 minimum_interval: Duration,
2431 lock_wait: Duration,
2432) -> Result<SharedIncrementalTargetMaintenanceSchedule, WasmBuildError> {
2433 let schedule_root = canonical.join(".ic-testkit");
2434 let maintenance_identity = policy.maintenance_identity();
2435 let schedule_started = Instant::now();
2436 let due = cache_maintenance_due(
2437 &schedule_root,
2438 Some(minimum_interval),
2439 &maintenance_identity,
2440 )
2441 .map_err(wasm_cache_fs_error)?;
2442 let schedule_check = schedule_started.elapsed();
2443 if !due {
2444 return Ok(SharedIncrementalTargetMaintenanceSchedule::Skipped(
2445 SharedIncrementalTargetMaintenanceOutcome::Skipped {
2446 target_dir: canonical.to_owned(),
2447 lock_wait,
2448 schedule_check,
2449 },
2450 ));
2451 }
2452 Ok(SharedIncrementalTargetMaintenanceSchedule::Due(
2453 DueSharedIncrementalTargetMaintenance {
2454 schedule_root,
2455 maintenance_identity,
2456 schedule_check,
2457 },
2458 ))
2459}
2460
2461fn perform_due_shared_incremental_target_maintenance(
2462 canonical: &Path,
2463 policy: SharedIncrementalTargetPrunePolicy,
2464 lock_wait: Duration,
2465 due: DueSharedIncrementalTargetMaintenance,
2466) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2467 let DueSharedIncrementalTargetMaintenance {
2468 schedule_root,
2469 maintenance_identity,
2470 schedule_check,
2471 } = due;
2472 let maintenance = maintain_shared_incremental_target_locked(canonical, policy, lock_wait)?;
2473 record_cache_maintenance(&schedule_root, &maintenance_identity).map_err(wasm_cache_fs_error)?;
2474 Ok(SharedIncrementalTargetMaintenanceOutcome::Performed {
2475 maintenance,
2476 schedule_check,
2477 })
2478}
2479
2480fn maintain_shared_incremental_target_locked(
2481 canonical: &Path,
2482 policy: SharedIncrementalTargetPrunePolicy,
2483 lock_wait: Duration,
2484) -> Result<SharedIncrementalTargetMaintenance, WasmBuildError> {
2485 let started = Instant::now();
2486 let logical_size_bytes_before =
2487 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2488 operation: "measure shared incremental Cargo target before maintenance",
2489 path: canonical.to_owned(),
2490 source,
2491 })?;
2492 let last_used_before =
2493 cache_entry_last_used(canonical).map_err(|source| WasmBuildError::Io {
2494 operation: "read shared incremental Cargo target use time before maintenance",
2495 path: canonical.to_owned(),
2496 source,
2497 })?;
2498 let expired = policy.max_age.is_some_and(|max_age| {
2499 SystemTime::now()
2500 .duration_since(last_used_before)
2501 .is_ok_and(|age| age > max_age)
2502 });
2503 let oversized = policy
2504 .max_size_bytes
2505 .is_some_and(|max_size_bytes| logical_size_bytes_before > max_size_bytes);
2506 let cleared = expired || oversized;
2507 if cleared {
2508 clear_shared_incremental_target_contents(canonical)?;
2509 record_cache_entry_use(canonical)?;
2510 }
2511 let logical_size_bytes_after = if cleared {
2512 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2513 operation: "measure shared incremental Cargo target after maintenance",
2514 path: canonical.to_owned(),
2515 source,
2516 })?
2517 } else {
2518 logical_size_bytes_before
2519 };
2520 Ok(SharedIncrementalTargetMaintenance {
2521 target_dir: canonical.to_owned(),
2522 logical_size_bytes_before,
2523 logical_size_bytes_after,
2524 last_used_before,
2525 cleared,
2526 lock_wait,
2527 maintenance: started.elapsed(),
2528 })
2529}
2530
2531fn clear_shared_incremental_target_contents(target_dir: &Path) -> Result<(), WasmBuildError> {
2532 let entries = fs::read_dir(target_dir).map_err(|source| WasmBuildError::Io {
2533 operation: "read shared incremental Cargo target for maintenance",
2534 path: target_dir.to_owned(),
2535 source,
2536 })?;
2537 for entry in entries {
2538 let path = entry
2539 .map_err(|source| WasmBuildError::Io {
2540 operation: "read shared incremental Cargo target entry for maintenance",
2541 path: target_dir.to_owned(),
2542 source,
2543 })?
2544 .path();
2545 let preserved = path
2546 .file_name()
2547 .is_some_and(|name| name == ".ic-testkit" || name == "CACHEDIR.TAG");
2548 if !preserved {
2549 remove_path_if_present(&path).map_err(|source| WasmBuildError::Io {
2550 operation: "clear shared incremental Cargo target entry",
2551 path,
2552 source,
2553 })?;
2554 }
2555 }
2556 Ok(())
2557}
2558
2559pub fn build_wasm_canisters_cached(
2581 spec: &WasmBuildSpec,
2582) -> Result<WasmBuildOutcome, WasmBuildError> {
2583 build_wasm_canisters_cached_internal(spec, &mut ProgressReporter::silent(), None)
2584}
2585
2586pub(super) fn build_wasm_canisters_cached_in_batch(
2587 spec: &WasmBuildSpec,
2588 index: usize,
2589 resolver: &mut WasmBuildBatchInputResolver<'_, '_>,
2590) -> WasmBuildBatchAttempt {
2591 let started = Instant::now();
2592 let mut progress = ProgressReporter::silent();
2593 let result = build_wasm_canisters_cached_internal(spec, &mut progress, Some((resolver, index)));
2594 if result
2595 .as_ref()
2596 .is_err_and(WasmBuildError::indicates_input_change)
2597 {
2598 resolver.invalidate_source_lease();
2599 }
2600 batch_result(result, &progress, started.elapsed())
2601}
2602
2603pub fn build_wasm_canisters_cached_with_progress<F>(
2620 spec: &WasmBuildSpec,
2621 config: WasmBuildProgressConfig,
2622 mut observer: F,
2623) -> Result<WasmBuildOutcome, WasmBuildError>
2624where
2625 F: FnMut(WasmBuildProgressEvent),
2626{
2627 if config.heartbeat_interval == Some(Duration::ZERO) {
2628 return Err(WasmBuildError::InvalidSpec {
2629 message: "Wasm build progress heartbeat interval must be greater than zero".to_owned(),
2630 });
2631 }
2632 build_wasm_canisters_cached_internal(
2633 spec,
2634 &mut ProgressReporter::observed(config, &mut observer),
2635 None,
2636 )
2637}
2638
2639pub(super) fn build_wasm_canisters_cached_in_batch_with_progress<F>(
2640 spec: &WasmBuildSpec,
2641 index: usize,
2642 resolver: &mut WasmBuildBatchInputResolver<'_, '_>,
2643 config: WasmBuildProgressConfig,
2644 mut observer: F,
2645) -> WasmBuildBatchAttempt
2646where
2647 F: FnMut(WasmBuildProgressEvent),
2648{
2649 if config.heartbeat_interval == Some(Duration::ZERO) {
2650 return WasmBuildBatchAttempt {
2651 result: Err((
2652 WasmBuildError::InvalidSpec {
2653 message: "Wasm build progress heartbeat interval must be greater than zero"
2654 .to_owned(),
2655 },
2656 WasmBuildFailureDetails::specification(Duration::ZERO),
2657 )),
2658 };
2659 }
2660 let started = Instant::now();
2661 let mut progress = ProgressReporter::observed(config, &mut observer);
2662 let result = build_wasm_canisters_cached_internal(spec, &mut progress, Some((resolver, index)));
2663 if result
2664 .as_ref()
2665 .is_err_and(WasmBuildError::indicates_input_change)
2666 {
2667 resolver.invalidate_source_lease();
2668 }
2669 batch_result(result, &progress, started.elapsed())
2670}
2671
2672fn batch_result(
2673 result: Result<WasmBuildOutcome, WasmBuildError>,
2674 progress: &ProgressReporter<'_>,
2675 total: Duration,
2676) -> WasmBuildBatchAttempt {
2677 WasmBuildBatchAttempt {
2678 result: result.map_err(|error| {
2679 let details = progress.failure_details(&error, total);
2680 (error, details)
2681 }),
2682 }
2683}
2684
2685fn batch_source_assumptions(
2686 batch_resolution: Option<&(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2687) -> (bool, Option<Arc<RwLock<bool>>>) {
2688 batch_resolution.map_or((false, None), |(resolver, _)| {
2689 (
2690 resolver.assumes_sources_immutable(),
2691 resolver.prepared_invalidation(),
2692 )
2693 })
2694}
2695
2696fn build_wasm_canisters_cached_internal(
2697 spec: &WasmBuildSpec,
2698 progress: &mut ProgressReporter<'_>,
2699 mut batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2700) -> Result<WasmBuildOutcome, WasmBuildError> {
2701 let total_started = Instant::now();
2702 validate_spec(spec)?;
2703 let (assumes_sources_immutable, prepared_invalidation) =
2704 batch_source_assumptions(batch_resolution.as_ref());
2705 progress.emit(WasmBuildProgressEvent::Started);
2706 if spec.shared_incremental_maintenance_config.is_some() {
2707 let outcome = build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2708 spec,
2709 total_started,
2710 progress,
2711 batch_resolution.take(),
2712 )?;
2713 emit_finished_progress(&outcome, progress);
2714 return Ok(outcome);
2715 }
2716 let (cache_lock, first_lock_wait) =
2717 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2718 ensure_cache_directory_tag(&spec.target_dir)?;
2719
2720 let resolved = resolve_initial_inputs(spec, batch_resolution.take(), progress)?;
2721 let isolated_acquisition =
2722 WasmAcquisitionContext::isolated(prepared_invalidation.clone(), assumes_sources_immutable);
2723 if let Some(outcome) = try_reuse_wasm_artifacts(
2724 spec,
2725 &resolved,
2726 first_lock_wait,
2727 &isolated_acquisition,
2728 total_started,
2729 progress,
2730 )? {
2731 emit_finished_progress(&outcome, progress);
2732 return Ok(outcome);
2733 }
2734 progress.emit(WasmBuildProgressEvent::CacheMiss {
2735 fingerprint: resolved.fingerprint,
2736 });
2737
2738 let outcome = match &spec.cache_mode {
2739 WasmBuildCacheMode::Isolated => {
2740 let cache_entry = cache_entry_directory(spec, resolved.fingerprint);
2741 build_wasm_cache_miss(
2742 spec,
2743 &resolved,
2744 first_lock_wait,
2745 isolated_acquisition,
2746 &cache_entry,
2747 total_started,
2748 progress,
2749 )
2750 }
2751 WasmBuildCacheMode::SharedIncremental { .. } => {
2752 drop(cache_lock);
2753 build_wasm_with_shared_incremental(
2754 spec,
2755 resolved,
2756 first_lock_wait,
2757 assumes_sources_immutable,
2758 prepared_invalidation,
2759 total_started,
2760 progress,
2761 )
2762 }
2763 }?;
2764 emit_finished_progress(&outcome, progress);
2765 Ok(outcome)
2766}
2767
2768fn build_wasm_with_shared_incremental(
2769 spec: &WasmBuildSpec,
2770 resolved: ResolvedCargoBuildInputs,
2771 first_lock_wait: Duration,
2772 assumes_sources_immutable: bool,
2773 prepared_invalidation: Option<Arc<RwLock<bool>>>,
2774 total_started: Instant,
2775 progress: &mut ProgressReporter<'_>,
2776) -> Result<WasmBuildOutcome, WasmBuildError> {
2777 let (shared_lock, shared_lock_wait, shared_target) =
2778 lock_shared_incremental_target_with_progress(spec, progress)?;
2779 let (_cache_lock, second_lock_wait) =
2780 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2781 ensure_cache_directory_tag(&spec.target_dir)?;
2782
2783 let current = if assumes_sources_immutable {
2784 resolved
2785 } else {
2786 let mut current = resolve_inputs_with_progress(spec, progress)?;
2787 current.timings.include(resolved.timings);
2788 current
2789 };
2790 let lock_wait = first_lock_wait.saturating_add(second_lock_wait);
2791 let shared_incremental = WasmAcquisitionContext::shared(
2792 shared_lock_wait,
2793 None,
2794 prepared_invalidation,
2795 assumes_sources_immutable,
2796 );
2797 if let Some(outcome) = try_reuse_wasm_artifacts(
2798 spec,
2799 ¤t,
2800 lock_wait,
2801 &shared_incremental,
2802 total_started,
2803 progress,
2804 )? {
2805 return Ok(outcome);
2806 }
2807
2808 let outcome = build_wasm_cache_miss(
2809 spec,
2810 ¤t,
2811 lock_wait,
2812 shared_incremental,
2813 &shared_target,
2814 total_started,
2815 progress,
2816 );
2817 drop(shared_lock);
2818 outcome
2819}
2820
2821fn build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2822 spec: &WasmBuildSpec,
2823 total_started: Instant,
2824 progress: &mut ProgressReporter<'_>,
2825 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2826) -> Result<WasmBuildOutcome, WasmBuildError> {
2827 let (assumes_sources_immutable, prepared_invalidation) =
2828 batch_source_assumptions(batch_resolution.as_ref());
2829 let (_shared_lock, shared_lock_wait, shared_target) =
2830 lock_shared_incremental_target_with_progress(spec, progress)?;
2831 let (_cache_lock, lock_wait) = lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2832 ensure_cache_directory_tag(&spec.target_dir)?;
2833
2834 let resolved = resolve_initial_inputs(spec, batch_resolution, progress)?;
2837 let shared_maintenance = perform_configured_shared_incremental_target_maintenance(
2838 spec,
2839 &shared_target,
2840 shared_lock_wait,
2841 progress,
2842 )?;
2843 let shared_incremental = WasmAcquisitionContext::shared(
2844 shared_lock_wait,
2845 Some(shared_maintenance),
2846 prepared_invalidation,
2847 assumes_sources_immutable,
2848 );
2849 if let Some(outcome) = try_reuse_wasm_artifacts(
2850 spec,
2851 &resolved,
2852 lock_wait,
2853 &shared_incremental,
2854 total_started,
2855 progress,
2856 )? {
2857 return Ok(outcome);
2858 }
2859 progress.emit(WasmBuildProgressEvent::CacheMiss {
2860 fingerprint: resolved.fingerprint,
2861 });
2862 build_wasm_cache_miss(
2863 spec,
2864 &resolved,
2865 lock_wait,
2866 shared_incremental,
2867 &shared_target,
2868 total_started,
2869 progress,
2870 )
2871}
2872
2873fn perform_configured_shared_incremental_target_maintenance(
2874 spec: &WasmBuildSpec,
2875 shared_target: &Path,
2876 lock_wait: Duration,
2877 progress: &mut ProgressReporter<'_>,
2878) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2879 let config = spec
2880 .shared_incremental_maintenance_config
2881 .expect("configured shared-target maintenance must have settings");
2882 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceStarted {
2883 target_dir: shared_target.to_owned(),
2884 });
2885 let result = progress.run_phase(WasmBuildProgressPhase::SharedTargetMaintenance, || {
2886 let schedule = schedule_shared_incremental_target_maintenance(
2887 shared_target,
2888 config.policy,
2889 config.minimum_interval,
2890 lock_wait,
2891 )?;
2892 match schedule {
2893 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => Ok(outcome),
2894 SharedIncrementalTargetMaintenanceSchedule::Due(due) => {
2895 perform_due_shared_incremental_target_maintenance(
2896 shared_target,
2897 config.policy,
2898 lock_wait,
2899 due,
2900 )
2901 }
2902 }
2903 });
2904 let outcome = integrated_shared_maintenance_result(config, shared_target, lock_wait, result)?;
2905 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceFinished {
2906 outcome: outcome.clone(),
2907 });
2908 Ok(outcome)
2909}
2910
2911fn integrated_shared_maintenance_result(
2912 config: SharedIncrementalTargetMaintenanceConfig,
2913 shared_target: &Path,
2914 lock_wait: Duration,
2915 result: Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError>,
2916) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2917 match result {
2918 Ok(outcome) => Ok(outcome),
2919 Err(error)
2920 if config.failure_mode == SharedIncrementalTargetMaintenanceFailureMode::BestEffort =>
2921 {
2922 Ok(SharedIncrementalTargetMaintenanceOutcome::Failed {
2923 target_dir: shared_target.to_owned(),
2924 lock_wait,
2925 message: error.to_string(),
2926 })
2927 }
2928 Err(error) => Err(error),
2929 }
2930}
2931
2932fn resolve_inputs_with_progress(
2933 spec: &WasmBuildSpec,
2934 progress: &mut ProgressReporter<'_>,
2935) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2936 let resolved = build_fingerprint_with_progress(spec, progress)?;
2937 validate_wasm_library_outputs(&resolved, progress)?;
2938 progress.emit(WasmBuildProgressEvent::InputsResolved {
2939 fingerprint: resolved.fingerprint,
2940 input_digest: resolved.input_digest,
2941 elapsed: resolved.timings.total,
2942 });
2943 Ok(resolved)
2944}
2945
2946fn resolve_initial_inputs(
2947 spec: &WasmBuildSpec,
2948 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2949 progress: &mut ProgressReporter<'_>,
2950) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2951 let resolved = if let Some((resolver, index)) = batch_resolution {
2952 resolver.resolve(index, progress)?
2953 } else {
2954 build_fingerprint_with_progress(spec, progress)?
2955 };
2956 validate_wasm_library_outputs(&resolved, progress)?;
2957 progress.emit(WasmBuildProgressEvent::InputsResolved {
2958 fingerprint: resolved.fingerprint,
2959 input_digest: resolved.input_digest,
2960 elapsed: resolved.timings.total,
2961 });
2962 Ok(resolved)
2963}
2964
2965fn validate_wasm_library_outputs(
2966 resolved: &ResolvedCargoBuildInputs,
2967 progress: &mut ProgressReporter<'_>,
2968) -> Result<(), WasmBuildError> {
2969 if let Some(message) = &resolved.wasm_artifact_error {
2970 progress.begin_phase(WasmBuildFailurePhase::InputDiscovery);
2971 return Err(WasmBuildError::InvalidSpec {
2972 message: message.clone(),
2973 });
2974 }
2975 Ok(())
2976}
2977
2978fn emit_finished_progress(outcome: &WasmBuildOutcome, progress: &mut ProgressReporter<'_>) {
2979 let state = if outcome.is_reused() {
2980 progress.emit(WasmBuildProgressEvent::CacheHit {
2981 fingerprint: outcome.record().fingerprint,
2982 });
2983 WasmBuildProgressOutcome::Reused
2984 } else {
2985 WasmBuildProgressOutcome::Built
2986 };
2987 progress.emit(WasmBuildProgressEvent::Finished {
2988 outcome: state,
2989 fingerprint: outcome.record().fingerprint,
2990 elapsed: outcome.record().timings.total,
2991 });
2992}
2993
2994#[derive(Clone, Debug, Default)]
2995struct WasmAcquisitionContext {
2996 assumes_sources_immutable: bool,
2997 lock_wait: Option<Duration>,
2998 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
2999 prepared_invalidation: Option<Arc<RwLock<bool>>>,
3000}
3001
3002impl WasmAcquisitionContext {
3003 fn isolated(
3004 prepared_invalidation: Option<Arc<RwLock<bool>>>,
3005 assumes_sources_immutable: bool,
3006 ) -> Self {
3007 Self {
3008 assumes_sources_immutable,
3009 prepared_invalidation,
3010 ..Self::default()
3011 }
3012 }
3013
3014 const fn shared(
3015 lock_wait: Duration,
3016 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
3017 prepared_invalidation: Option<Arc<RwLock<bool>>>,
3018 assumes_sources_immutable: bool,
3019 ) -> Self {
3020 Self {
3021 assumes_sources_immutable,
3022 lock_wait: Some(lock_wait),
3023 maintenance,
3024 prepared_invalidation,
3025 }
3026 }
3027
3028 fn lock_prepared_publication(
3029 &self,
3030 ) -> Result<Option<std::sync::RwLockReadGuard<'_, bool>>, WasmBuildError> {
3031 let guard = self.prepared_invalidation.as_deref().map(|invalidation| {
3032 invalidation
3033 .read()
3034 .unwrap_or_else(std::sync::PoisonError::into_inner)
3035 });
3036 if guard.as_deref().is_some_and(|invalidated| *invalidated) {
3037 return Err(WasmBuildError::PreparedInputSnapshotInvalidated);
3038 }
3039 Ok(guard)
3040 }
3041}
3042
3043fn try_reuse_wasm_artifacts(
3044 spec: &WasmBuildSpec,
3045 resolved: &ResolvedCargoBuildInputs,
3046 lock_wait: Duration,
3047 shared_incremental: &WasmAcquisitionContext,
3048 total_started: Instant,
3049 progress: &mut ProgressReporter<'_>,
3050) -> Result<Option<WasmBuildOutcome>, WasmBuildError> {
3051 let _publication_guard = shared_incremental.lock_prepared_publication()?;
3052 let fingerprint = resolved.fingerprint;
3053 let artifacts = expected_artifacts(spec, &spec.target_dir);
3054 let cache_entry = cache_entry_directory(spec, fingerprint);
3055 let cached_artifacts = expected_artifacts(spec, &cache_entry);
3056 let cached_info = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3057 validated_artifact_set(&cached_artifacts, fingerprint)
3058 });
3059 let artifact_info = if let Some(info) = cached_info {
3060 info
3061 } else {
3062 let public_is_current = progress
3065 .run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3066 validated_artifact_set(&artifacts, fingerprint).is_some()
3067 });
3068 if !public_is_current {
3069 return Ok(None);
3070 }
3071 reconstruct_exact_cache_entry(spec, &artifacts, &cache_entry, fingerprint, progress)?
3072 };
3073 progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3074 materialize_artifacts(
3075 &cached_artifacts,
3076 &artifacts,
3077 fingerprint,
3078 &artifact_info,
3079 true,
3080 )?;
3081 record_cache_entry_use(&cache_entry)
3082 })?;
3083 let input_resolution = validate_reused_inputs(spec, resolved, shared_incremental, progress)?;
3084 Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
3085 spec,
3086 BuildRecordInput {
3087 fingerprint,
3088 input_digest: resolved.input_digest,
3089 lock_wait,
3090 shared_incremental: shared_incremental.clone(),
3091 input_resolution,
3092 cargo_build: None,
3093 active_entry: &cache_entry,
3094 },
3095 total_started,
3096 progress,
3097 )?)))
3098}
3099
3100fn validate_reused_inputs(
3101 spec: &WasmBuildSpec,
3102 resolved: &ResolvedCargoBuildInputs,
3103 context: &WasmAcquisitionContext,
3104 progress: &mut ProgressReporter<'_>,
3105) -> Result<WasmInputResolutionTimings, WasmBuildError> {
3106 let mut timings = resolved.timings;
3107 if !context.assumes_sources_immutable {
3108 let verified = verify_resolved_inputs(spec, resolved, progress)?;
3109 timings.include(verified.timings);
3110 }
3111 Ok(timings)
3112}
3113
3114fn verify_resolved_inputs(
3115 spec: &WasmBuildSpec,
3116 resolved: &ResolvedCargoBuildInputs,
3117 progress: &mut ProgressReporter<'_>,
3118) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3119 let verified = resolve_inputs_with_progress(spec, progress)?;
3120 for (before, after) in [
3121 (resolved.validation_digest, verified.validation_digest),
3122 (resolved.fingerprint, verified.fingerprint),
3123 ] {
3124 if before != after {
3125 return Err(WasmBuildError::InputsChangedDuringAcquisition { before, after });
3126 }
3127 }
3128 Ok(verified)
3129}
3130
3131fn reconstruct_exact_cache_entry(
3132 spec: &WasmBuildSpec,
3133 artifacts: &[PathBuf],
3134 cache_entry: &Path,
3135 fingerprint: InputDigest,
3136 progress: &mut ProgressReporter<'_>,
3137) -> Result<Vec<FileDigest>, WasmBuildError> {
3138 let cached_artifacts = expected_artifacts(spec, cache_entry);
3139 progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3140 remove_unretained_entry(cache_entry).map_err(wasm_cache_fs_error)?;
3141 create_dir_all(
3142 cache_entry,
3143 "create content-addressed Cargo target directory",
3144 )
3145 })?;
3146 let incomplete = IncompleteBuildDirectory::new(cache_entry.to_owned());
3147 let result = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3148 copy_wasm_artifacts(artifacts, &cached_artifacts)?;
3149 let missing = missing_artifacts(&cached_artifacts);
3150 if !missing.is_empty() {
3151 return Err(WasmBuildError::MissingArtifacts { paths: missing });
3152 }
3153 publish_artifact_stamps(&cached_artifacts, fingerprint)
3156 });
3157 finish_fingerprint_build(result, incomplete, progress)
3158}
3159
3160fn build_wasm_cache_miss(
3161 spec: &WasmBuildSpec,
3162 resolved: &ResolvedCargoBuildInputs,
3163 lock_wait: Duration,
3164 shared_incremental: WasmAcquisitionContext,
3165 cargo_target_dir: &Path,
3166 total_started: Instant,
3167 progress: &mut ProgressReporter<'_>,
3168) -> Result<WasmBuildOutcome, WasmBuildError> {
3169 let fingerprint = resolved.fingerprint;
3170 let mut input_resolution = resolved.timings;
3171 let artifacts = expected_artifacts(spec, &spec.target_dir);
3172 let cache_entry = cache_entry_directory(spec, fingerprint);
3173 let preparation_started = Instant::now();
3174 progress.begin_phase(WasmBuildFailurePhase::ArtifactPublication);
3175 let preparation_result = (|| {
3176 remove_unretained_entry(&cache_entry).map_err(wasm_cache_fs_error)?;
3177 create_dir_all(
3178 &cache_entry,
3179 "create content-addressed Cargo target directory",
3180 )
3181 })();
3182 progress.record_phase(
3183 WasmBuildFailurePhase::ArtifactPublication,
3184 preparation_started.elapsed(),
3185 );
3186 preparation_result?;
3187 let incomplete_directory = IncompleteBuildDirectory::new(cache_entry.clone());
3188 let build_result = (|| {
3189 if matches!(
3190 spec.cache_mode,
3191 WasmBuildCacheMode::SharedIncremental { .. }
3192 ) {
3193 record_cache_entry_use(cargo_target_dir)?;
3194 }
3195 let build_started = Instant::now();
3196 progress.begin_phase(WasmBuildFailurePhase::CargoBuild);
3197 let cargo_result = run_cargo_build(spec, cargo_target_dir, progress);
3198 let cargo_build = build_started.elapsed();
3199 progress.record_phase(WasmBuildFailurePhase::CargoBuild, cargo_build);
3200 cargo_result?;
3201 let built_artifacts = expected_artifacts(spec, cargo_target_dir);
3202 let validation_started = Instant::now();
3203 progress.begin_phase(WasmBuildFailurePhase::ArtifactPublication);
3204 let missing = missing_artifacts(&built_artifacts);
3205 progress.record_phase(
3206 WasmBuildFailurePhase::ArtifactPublication,
3207 validation_started.elapsed(),
3208 );
3209 if !missing.is_empty() {
3210 return Err(WasmBuildError::MissingArtifacts { paths: missing });
3211 }
3212
3213 let verified = verify_resolved_inputs(spec, resolved, progress)?;
3214 input_resolution.include(verified.timings);
3215
3216 let publication_guard = shared_incremental.lock_prepared_publication()?;
3221
3222 let cached_artifacts = expected_artifacts(spec, &cache_entry);
3223 progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3224 if cargo_target_dir != cache_entry {
3225 copy_wasm_artifacts(&built_artifacts, &cached_artifacts)?;
3226 }
3227 let info = publish_artifact_stamps(&cached_artifacts, fingerprint)?;
3228 materialize_artifacts(&cached_artifacts, &artifacts, fingerprint, &info, false)?;
3229 record_cache_entry_use(&cache_entry)
3230 })?;
3231 drop(publication_guard);
3232
3233 Ok(WasmBuildOutcome::Built(complete_build_record(
3234 spec,
3235 BuildRecordInput {
3236 fingerprint,
3237 input_digest: resolved.input_digest,
3238 lock_wait,
3239 shared_incremental,
3240 input_resolution,
3241 cargo_build: Some(cargo_build),
3242 active_entry: &cache_entry,
3243 },
3244 total_started,
3245 progress,
3246 )?))
3247 })();
3248 finish_fingerprint_build(build_result, incomplete_directory, progress)
3249}
3250
3251pub fn prune_wasm_build_cache(
3263 target_dir: &Path,
3264 policy: ArtifactCachePrunePolicy,
3265) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3266 let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
3267 ensure_cache_directory_tag(target_dir)?;
3268
3269 prune_wasm_build_cache_locked(target_dir, policy, None)
3270}
3271
3272struct BuildRecordInput<'a> {
3273 fingerprint: InputDigest,
3274 input_digest: InputDigest,
3275 lock_wait: Duration,
3276 shared_incremental: WasmAcquisitionContext,
3277 input_resolution: WasmInputResolutionTimings,
3278 cargo_build: Option<Duration>,
3279 active_entry: &'a Path,
3280}
3281
3282fn complete_build_record(
3283 spec: &WasmBuildSpec,
3284 input: BuildRecordInput<'_>,
3285 total_started: Instant,
3286 progress: &mut ProgressReporter<'_>,
3287) -> Result<WasmBuildRecord, WasmBuildError> {
3288 let retention = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3289 RetainedCacheEntry::acquire(input.active_entry).map_err(wasm_cache_fs_error)
3290 })?;
3291 let (maintenance, cache_maintenance) = spec.prune_policy.map_or((None, None), |policy| {
3292 progress.run_phase(WasmBuildProgressPhase::ExactCacheMaintenance, || {
3293 let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
3294 let identity = policy.maintenance_identity();
3295 perform_scheduled_cache_maintenance(&cache_root, spec.prune_interval, &identity, || {
3296 prune_wasm_build_cache_locked(&spec.target_dir, policy, Some(input.active_entry))
3297 .map_err(|error| error.to_string())
3298 })
3299 })
3300 });
3301 Ok(WasmBuildRecord {
3302 fingerprint: input.fingerprint,
3303 input_digest: input.input_digest,
3304 artifacts: expected_artifacts(spec, input.active_entry),
3305 retention,
3306 timings: WasmBuildTimings {
3307 lock_wait: input.lock_wait,
3308 shared_incremental_lock_wait: input.shared_incremental.lock_wait,
3309 input_resolution: input.input_resolution,
3310 cargo_build: input.cargo_build,
3311 cache_maintenance,
3312 total: total_started.elapsed(),
3313 },
3314 maintenance,
3315 shared_incremental_maintenance: input.shared_incremental.maintenance,
3316 })
3317}
3318
3319fn prune_wasm_build_cache_locked(
3320 target_dir: &Path,
3321 policy: ArtifactCachePrunePolicy,
3322 protected_entry: Option<&Path>,
3323) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3324 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
3325 prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
3326 .map_err(wasm_cache_fs_error)
3327}
3328
3329struct IncompleteBuildDirectory {
3330 path: PathBuf,
3331 armed: bool,
3332}
3333
3334impl IncompleteBuildDirectory {
3335 const fn new(path: PathBuf) -> Self {
3336 Self { path, armed: true }
3337 }
3338
3339 fn preserve(mut self) {
3340 self.armed = false;
3341 }
3342
3343 fn cleanup(mut self) -> io::Result<()> {
3344 let result = remove_path_if_present(&self.path);
3345 if result.is_ok() {
3346 self.armed = false;
3347 }
3348 result
3349 }
3350}
3351
3352impl Drop for IncompleteBuildDirectory {
3353 fn drop(&mut self) {
3354 if self.armed {
3355 let _ = remove_path_if_present(&self.path);
3356 }
3357 }
3358}
3359
3360fn finish_fingerprint_build<T>(
3361 result: Result<T, WasmBuildError>,
3362 incomplete_directory: IncompleteBuildDirectory,
3363 progress: &mut ProgressReporter<'_>,
3364) -> Result<T, WasmBuildError> {
3365 match result {
3366 Ok(outcome) => {
3367 incomplete_directory.preserve();
3368 Ok(outcome)
3369 }
3370 Err(build_error) => Err(cleanup_failed_fingerprint_build(
3371 build_error,
3372 incomplete_directory,
3373 progress,
3374 )),
3375 }
3376}
3377
3378fn cleanup_failed_fingerprint_build(
3379 build_error: WasmBuildError,
3380 incomplete_directory: IncompleteBuildDirectory,
3381 progress: &mut ProgressReporter<'_>,
3382) -> WasmBuildError {
3383 let path = incomplete_directory.path.clone();
3384 let primary_phase = progress.failure_phase;
3385 let cleanup_started = Instant::now();
3386 let cleanup = incomplete_directory.cleanup();
3387 progress.record_phase(WasmBuildFailurePhase::Cleanup, cleanup_started.elapsed());
3388 match cleanup {
3389 Ok(()) => {
3390 progress.failure_phase = primary_phase;
3391 build_error
3392 }
3393 Err(source) => WasmBuildError::FailedBuildCleanup {
3394 build_error: Box::new(build_error),
3395 path,
3396 source,
3397 },
3398 }
3399}
3400
3401fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
3402 create_dir_all(target_dir, "create Cargo target directory")?;
3403 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3404 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)
3405}
3406
3407fn lock_wasm_build_cache_with_progress(
3408 target_dir: &Path,
3409 progress: &mut ProgressReporter<'_>,
3410) -> Result<(File, Duration), WasmBuildError> {
3411 progress.begin_phase(WasmBuildFailurePhase::ExactCacheCoordination);
3412 create_dir_all(target_dir, "create Cargo target directory")?;
3413 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3414 lock_cache_file_with_progress(&lock_path, WasmBuildProgressPhase::ExactCacheLock, progress)
3415}
3416
3417fn lock_shared_incremental_target(
3418 spec: &WasmBuildSpec,
3419) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3420 lock_shared_incremental_target_internal(spec, None)
3421}
3422
3423fn lock_shared_incremental_target_with_progress(
3424 spec: &WasmBuildSpec,
3425 progress: &mut ProgressReporter<'_>,
3426) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3427 let target_dir = shared_incremental_target(spec)
3428 .expect("validated shared acquisition must have a shared Cargo target");
3429 progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted { target_dir });
3430 let (lock, wait, canonical) = lock_shared_incremental_target_internal(spec, Some(progress))?;
3431 progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
3432 target_dir: canonical.clone(),
3433 wait,
3434 });
3435 Ok((lock, wait, canonical))
3436}
3437
3438fn lock_shared_incremental_target_internal(
3439 spec: &WasmBuildSpec,
3440 mut progress: Option<&mut ProgressReporter<'_>>,
3441) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3442 if let Some(progress) = progress.as_deref_mut() {
3443 progress.begin_phase(WasmBuildFailurePhase::SharedTargetCoordination);
3444 }
3445 let target_dir =
3446 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
3447 message: "shared incremental target is not configured".to_owned(),
3448 })?;
3449 create_dir_all(
3450 &target_dir,
3451 "create shared incremental Cargo target directory",
3452 )?;
3453 ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
3454 let canonical = target_dir
3455 .canonicalize()
3456 .map_err(|source| WasmBuildError::Io {
3457 operation: "resolve shared incremental Cargo target directory",
3458 path: target_dir.clone(),
3459 source,
3460 })?;
3461 let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
3462 let (lock, wait) = if let Some(progress) = progress {
3463 lock_cache_file_with_progress(
3464 &lock_path,
3465 WasmBuildProgressPhase::SharedTargetLock,
3466 progress,
3467 )?
3468 } else {
3469 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)?
3470 };
3471 Ok((lock, wait, canonical))
3472}
3473
3474fn lock_cache_file_with_progress(
3475 lock_path: &Path,
3476 phase: WasmBuildProgressPhase,
3477 progress: &mut ProgressReporter<'_>,
3478) -> Result<(File, Duration), WasmBuildError> {
3479 let failure_phase = progress_failure_phase(phase);
3480 let started = Instant::now();
3481 progress.begin_phase(failure_phase);
3482 let result = if !progress.is_observed() || progress.config.heartbeat_interval.is_none() {
3483 lock_cache_file(lock_path).map_err(wasm_cache_fs_error)
3484 } else {
3485 let heartbeat_interval = progress
3486 .config
3487 .heartbeat_interval
3488 .expect("observed cache lock must have a heartbeat interval");
3489 lock_cache_file_with_wait_observer(lock_path, heartbeat_interval, |elapsed| {
3490 progress.emit_heartbeat_if_due(phase, elapsed);
3491 })
3492 .map_err(wasm_cache_fs_error)
3493 };
3494 progress.record_phase(failure_phase, started.elapsed());
3495 result
3496}
3497
3498fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
3499 ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
3500}
3501
3502fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
3503 record_entry_use(path).map_err(wasm_cache_fs_error)
3504}
3505
3506fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
3507 WasmBuildError::Io {
3508 operation: error.operation,
3509 path: error.path,
3510 source: error.source,
3511 }
3512}
3513
3514fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3515 if spec.packages.is_empty() {
3516 return Err(WasmBuildError::InvalidSpec {
3517 message: "at least one Cargo package is required".to_owned(),
3518 });
3519 }
3520 let mut profile_components = Path::new(&spec.profile_target_dir).components();
3521 if !matches!(
3522 (profile_components.next(), profile_components.next()),
3523 (Some(Component::Normal(_)), None)
3524 ) {
3525 return Err(WasmBuildError::InvalidSpec {
3526 message: "Cargo profile target directory must be one normal path component".to_owned(),
3527 });
3528 }
3529 if spec.target.is_empty() {
3530 return Err(WasmBuildError::InvalidSpec {
3531 message: "Cargo compilation target must not be empty".to_owned(),
3532 });
3533 }
3534 if spec.cargo_profile_args.iter().any(|argument| {
3535 matches!(argument.to_str(), Some("--help" | "--unit-graph"))
3536 || matches!(short_cargo_option(argument), Some((b'h', _)))
3537 }) {
3538 return Err(WasmBuildError::InvalidSpec {
3539 message:
3540 "Cargo help and build-graph flags exit without building and cannot be used for Wasm acquisition"
3541 .to_owned(),
3542 });
3543 }
3544 if spec.extra_env.contains_key(OsStr::new("CARGO_TARGET_DIR"))
3545 || spec.cargo_profile_args.iter().any(|argument| {
3546 argument == OsStr::new("--target-dir")
3547 || argument.as_encoded_bytes().starts_with(b"--target-dir=")
3548 })
3549 {
3550 return Err(WasmBuildError::InvalidSpec {
3551 message: "Cargo target directories are owned by the build specification; use target_dir or with_shared_incremental_target instead of command overrides".to_owned(),
3552 });
3553 }
3554 validate_cargo_target_selection(spec)?;
3555 if spec.cargo_profile_args.iter().any(|argument| {
3556 let option = argument
3557 .as_encoded_bytes()
3558 .split(|byte| *byte == b'=')
3559 .next()
3560 .unwrap_or_default();
3561 matches!(
3562 option,
3563 b"--manifest-path"
3564 | b"--target"
3565 | b"--package"
3566 | b"--workspace"
3567 | b"--all"
3568 | b"--exclude"
3569 | b"--config"
3570 ) || matches!(short_cargo_option(argument), Some((b'm' | b'p' | b'C', _)))
3571 }) {
3572 return Err(WasmBuildError::InvalidSpec {
3573 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(),
3574 });
3575 }
3576 validate_cargo_profile(spec)?;
3577 if matches!(
3578 &spec.cache_mode,
3579 WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
3580 ) {
3581 return Err(WasmBuildError::InvalidSpec {
3582 message: "shared incremental Cargo target directory must not be empty".to_owned(),
3583 });
3584 }
3585 if spec.shared_incremental_maintenance_config.is_some()
3586 && !matches!(
3587 spec.cache_mode,
3588 WasmBuildCacheMode::SharedIncremental { .. }
3589 )
3590 {
3591 return Err(WasmBuildError::InvalidSpec {
3592 message:
3593 "scheduled shared-target maintenance requires a shared incremental Cargo target"
3594 .to_owned(),
3595 });
3596 }
3597 Ok(())
3598}
3599
3600fn validate_cargo_target_selection(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3601 if let Some(example) = &spec.example {
3602 let mut components = Path::new(example).components();
3603 if spec.packages.len() != 1
3604 || !matches!(
3605 (components.next(), components.next()),
3606 (Some(Component::Normal(_)), None)
3607 )
3608 || spec
3609 .cargo_profile_args
3610 .iter()
3611 .any(|argument| argument == "--lib")
3612 {
3613 return Err(WasmBuildError::InvalidSpec {
3614 message: "a named Cargo example requires one package, a normal target name and no --lib override".to_owned(),
3615 });
3616 }
3617 }
3618 if spec.packages.iter().any(|package| {
3619 let mut components = Path::new(package).components();
3620 !matches!(
3621 (components.next(), components.next()),
3622 (Some(Component::Normal(_)), None)
3623 )
3624 }) {
3625 return Err(WasmBuildError::InvalidSpec {
3626 message: "Cargo package names must be normal path components".to_owned(),
3627 });
3628 }
3629 if spec.cargo_profile_args.iter().any(|argument| {
3630 let option = argument
3631 .as_encoded_bytes()
3632 .split(|byte| *byte == b'=')
3633 .next()
3634 .unwrap_or_default();
3635 matches!(
3636 option,
3637 b"--bin"
3638 | b"--bins"
3639 | b"--example"
3640 | b"--examples"
3641 | b"--test"
3642 | b"--tests"
3643 | b"--bench"
3644 | b"--benches"
3645 | b"--all-targets"
3646 )
3647 }) {
3648 return Err(WasmBuildError::InvalidSpec {
3649 message: "Cargo target selection is owned by the build specification; use with_example for a named cdylib example".to_owned(),
3650 });
3651 }
3652 Ok(())
3653}
3654
3655fn validate_cargo_profile(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3656 let mut selected = None;
3657 let mut arguments = spec.cargo_profile_args.iter();
3658 while let Some(argument) = arguments.next() {
3659 let profile = if argument == "--profile" {
3660 Some(
3661 arguments
3662 .next()
3663 .and_then(|value| value.to_str())
3664 .ok_or_else(|| WasmBuildError::InvalidSpec {
3665 message: "Cargo --profile requires a UTF-8 profile name".to_owned(),
3666 })?,
3667 )
3668 } else if let Some(profile) = argument.to_str().and_then(|s| s.strip_prefix("--profile=")) {
3669 Some(profile)
3670 } else {
3671 let bytes = argument.as_encoded_bytes();
3672 let short_option = short_cargo_option(argument);
3675 let flags_end = short_option.map_or(bytes.len(), |(_, index)| index);
3676 let release = argument == "--release"
3677 || (bytes.starts_with(b"-")
3678 && !bytes.starts_with(b"--")
3679 && bytes[..flags_end].contains(&b'r'));
3680 if matches!(short_option, Some((_, index)) if index + 1 == bytes.len()) {
3681 arguments.next();
3682 }
3683 release.then_some("release")
3684 };
3685 if let Some(profile) = profile
3686 && (profile.is_empty() || selected.replace(profile).is_some())
3687 {
3688 return Err(WasmBuildError::InvalidSpec {
3689 message: "select one nonempty Cargo profile".to_owned(),
3690 });
3691 }
3692 }
3693 let profile = selected.unwrap_or("dev");
3694 let expected = match profile {
3695 "dev" | "test" => "debug",
3696 "bench" => "release",
3697 custom => custom,
3698 };
3699 if spec.profile_target_dir != expected {
3700 return Err(WasmBuildError::InvalidSpec {
3701 message: format!(
3702 "Cargo profile {profile:?} writes to {expected:?}, but profile target directory is {:?}; select matching Cargo profile arguments and output directory",
3703 spec.profile_target_dir,
3704 ),
3705 });
3706 }
3707 Ok(())
3708}
3709
3710fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3711 build_fingerprint_with_progress(spec, &mut ProgressReporter::silent())
3712}
3713
3714fn build_fingerprint_with_progress(
3715 spec: &WasmBuildSpec,
3716 progress: &mut ProgressReporter<'_>,
3717) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3718 let total_started = Instant::now();
3719 let (cargo_identity, rustc_identity, tool_identity) = resolve_tool_identity(spec, progress)?;
3720
3721 let metadata_started = Instant::now();
3722 let metadata = progress.run_phase(WasmBuildProgressPhase::CargoMetadata, || {
3723 cargo_metadata(spec)
3724 })?;
3725 let cargo_metadata = metadata_started.elapsed();
3726
3727 let discovery_started = Instant::now();
3728 let (inputs, exclusions) =
3729 progress.run_phase(WasmBuildProgressPhase::InputDiscovery, || {
3730 let parsed = ParsedCargoMetadata::parse(&metadata)
3731 .map_err(|message| invalid_metadata(&message))?;
3732 resolve_local_inputs(spec, &parsed)
3733 })?;
3734 let input_discovery = discovery_started.elapsed();
3735
3736 let hashing_started = Instant::now();
3737 let (input_digest, validation_digest) =
3738 progress.run_phase(WasmBuildProgressPhase::ContentHashing, || {
3739 let mut cache = LabeledPathDigestCache::default();
3740 digest_resolved_local_inputs(
3741 &inputs,
3742 &exclusions,
3743 &mut cache,
3744 &spec.workspace_root,
3745 "hash Wasm build inputs",
3746 "hash semantic Wasm build inputs",
3747 )
3748 })?;
3749 let content_hashing = hashing_started.elapsed();
3750
3751 let fingerprint =
3752 finish_build_fingerprint(spec, &cargo_identity, &rustc_identity, input_digest);
3753 Ok(ResolvedCargoBuildInputs {
3754 fingerprint,
3755 input_digest,
3756 validation_digest,
3757 inputs: cargo_build_inputs(inputs.validation_inputs),
3758 exclusions: exclusions.into(),
3759 wasm_artifact_error: inputs.wasm_artifact_error,
3760 artifact_paths: expected_artifacts(spec, &spec.target_dir).into(),
3761 timings: WasmInputResolutionTimings {
3762 tool_identity,
3763 cargo_metadata,
3764 input_discovery,
3765 content_hashing,
3766 total: total_started.elapsed(),
3767 },
3768 })
3769}
3770
3771fn cargo_build_inputs(inputs: Vec<(PathBuf, PathBuf)>) -> Arc<[CargoBuildInput]> {
3772 inputs
3773 .into_iter()
3774 .map(|(label, path)| CargoBuildInput { label, path })
3775 .collect()
3776}
3777
3778fn finish_build_fingerprint(
3779 spec: &WasmBuildSpec,
3780 cargo_identity: &[u8],
3781 rustc_identity: &[u8],
3782 input_digest: InputDigest,
3783) -> InputDigest {
3784 let mut hasher = InputHasher::new(CACHE_FORMAT_VERSION);
3785 for package in &spec.packages {
3786 hasher.field("package", package.as_bytes());
3787 }
3788 hasher.field("target", spec.target.as_bytes());
3789 if let Some(example) = &spec.example {
3790 hasher.field("cargo-target-selection", b"example");
3791 hasher.field("cargo-example", example.as_bytes());
3792 } else {
3793 hasher.field("cargo-target-selection", b"lib");
3794 }
3795 hasher.field("profile-target-dir", spec.profile_target_dir.as_bytes());
3796 for argument in &spec.cargo_profile_args {
3797 hasher.field("cargo-argument", &os_bytes(argument));
3798 }
3799 for (key, value) in effective_environment(spec) {
3800 hasher.field("environment-key", &os_bytes(&key));
3801 if let Some(value) = value {
3802 hasher.field("environment-value", &os_bytes(&value));
3803 } else {
3804 hasher.field("environment-unset", b"");
3805 }
3806 }
3807 hasher.field("cargo-identity", cargo_identity);
3808 hasher.field("rustc-identity", rustc_identity);
3809 hasher.field("source-input-digest", input_digest.as_bytes());
3810 hasher.finish()
3811}
3812
3813fn command_identity(
3814 spec: &WasmBuildSpec,
3815 phase: WasmBuildPhase,
3816 program: &OsStr,
3817 arguments: &[&str],
3818) -> Result<Vec<u8>, WasmBuildError> {
3819 let mut command = Command::new(program);
3820 command.current_dir(&spec.workspace_root).args(arguments);
3821 apply_command_environment(&mut command, spec);
3822 let output = capture_probe_output(&mut command, phase, TOOL_IDENTITY_BYTES)?;
3823 ensure_command_success(phase, output).map(|output| {
3824 let mut identity = output.stdout;
3825 identity.extend_from_slice(&output.stderr);
3826 identity
3827 })
3828}
3829
3830fn cargo_metadata(spec: &WasmBuildSpec) -> Result<Value, WasmBuildError> {
3831 let mut command = Command::new(&spec.cargo_program);
3832 command
3833 .current_dir(&spec.workspace_root)
3834 .args(["metadata", "--format-version", "1"]);
3835 for argument in metadata_arguments(&spec.cargo_profile_args) {
3836 command.arg(argument);
3837 }
3838 apply_command_environment(&mut command, spec);
3839 let output = capture_probe_output(
3840 &mut command,
3841 WasmBuildPhase::CargoMetadata,
3842 CARGO_METADATA_BYTES,
3843 )?;
3844 let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
3845 serde_json::from_slice(&output.stdout).map_err(|error| WasmBuildError::InvalidMetadata {
3846 message: format!("Cargo metadata was not valid JSON: {error}"),
3847 })
3848}
3849
3850fn capture_probe_output(
3851 command: &mut Command,
3852 phase: WasmBuildPhase,
3853 stdout_bytes: usize,
3854) -> Result<CompletedExecution, WasmBuildError> {
3855 let result = capture_command(
3856 command,
3857 OutputLimits {
3858 stdout: OutputLimit::Terminate(stdout_bytes),
3859 stderr: OutputLimit::Terminate(TOOL_IDENTITY_BYTES),
3860 timeout: None,
3861 },
3862 )
3863 .and_then(CompletedExecution::require_complete);
3864 let evidence = match result {
3865 Ok(evidence) => evidence,
3866 Err(ToolError::Execution(error))
3867 if matches!(
3868 error.failure,
3869 ExecutionFailure::Io {
3870 operation: ExecutionOperation::Spawn,
3871 ..
3872 }
3873 ) && error.cleanup.is_none() =>
3874 {
3875 let ExecutionFailure::Io { source, .. } = error.failure else {
3876 unreachable!()
3877 };
3878 return Err(WasmBuildError::CommandSpawn {
3879 phase,
3880 program: command.get_program().to_owned(),
3881 source,
3882 });
3883 }
3884 Err(error) => {
3885 return Err(WasmBuildError::Io {
3886 operation: "capture complete Cargo/tool probe output",
3887 path: PathBuf::from(command.get_program()),
3888 source: io::Error::other(error),
3889 });
3890 }
3891 };
3892 Ok(evidence)
3893}
3894
3895fn metadata_arguments(arguments: &[OsString]) -> Vec<OsString> {
3896 let mut selected = Vec::new();
3897 let mut arguments = arguments.iter();
3898 while let Some(argument) = arguments.next() {
3899 if let Some((b'F', index)) = short_cargo_option(argument) {
3900 selected.push(argument.to_str().map_or_else(
3905 || argument.clone(),
3906 |text| OsString::from(format!("-F{}", &text[index + 1..])),
3907 ));
3908 if index + 1 == argument.as_encoded_bytes().len()
3909 && let Some(value) = arguments.next()
3910 {
3911 selected.push(value.clone());
3912 }
3913 continue;
3914 }
3915 let argument_text = argument.to_string_lossy();
3916 match argument_text.as_ref() {
3917 "--all-features" | "--no-default-features" | "--locked" | "--offline" | "--frozen" => {
3918 selected.push(argument.clone());
3919 }
3920 "--features" | "--filter-platform" => {
3921 selected.push(argument.clone());
3922 if let Some(value) = arguments.next() {
3923 selected.push(value.clone());
3924 }
3925 }
3926 _ if argument_text.starts_with("--features=")
3927 || argument_text.starts_with("--filter-platform=") =>
3928 {
3929 selected.push(argument.clone());
3930 }
3931 _ => {}
3932 }
3933 }
3934 selected
3935}
3936
3937fn short_cargo_option(argument: &OsStr) -> Option<(u8, usize)> {
3941 let bytes = argument.as_encoded_bytes();
3942 if !bytes.starts_with(b"-") || bytes.starts_with(b"--") {
3943 return None;
3944 }
3945 for (index, byte) in bytes.iter().copied().enumerate().skip(1) {
3946 match byte {
3947 b'v' | b'q' | b'r' => {}
3948 b'h' | b'F' | b'j' | b'Z' | b'm' | b'p' | b'C' => return Some((byte, index)),
3949 _ => return None,
3950 }
3951 }
3952 None
3953}
3954
3955struct MetadataPackage<'a> {
3956 id: &'a str,
3957 name: &'a str,
3958 version: &'a str,
3959 manifest_path: PathBuf,
3960 is_local: bool,
3961 source: Option<&'a str>,
3962 semantic_fields: Vec<(&'static str, Option<String>)>,
3963 cdylib_name: Option<&'a str>,
3964 cdylib_examples: Vec<&'a str>,
3965}
3966
3967struct ParsedCargoMetadata<'a> {
3970 packages: HashMap<&'a str, MetadataPackage<'a>>,
3971 workspace_members: HashSet<&'a str>,
3972 nodes: HashMap<&'a str, MetadataNode<'a>>,
3973 workspace_root: Option<&'a str>,
3974}
3975
3976struct MetadataNode<'a> {
3977 dependencies: Vec<&'a str>,
3978 value: &'a Value,
3979}
3980
3981impl<'a> ParsedCargoMetadata<'a> {
3982 fn parse(metadata: &'a Value) -> Result<Self, String> {
3983 let packages = metadata_packages(metadata)?;
3984 let workspace_members = metadata
3985 .get("workspace_members")
3986 .and_then(Value::as_array)
3987 .ok_or_else(|| "Cargo metadata has no workspace member array".to_owned())?
3988 .iter()
3989 .filter_map(Value::as_str)
3990 .collect();
3991 let values = metadata
3992 .pointer("/resolve/nodes")
3993 .and_then(Value::as_array)
3994 .ok_or_else(|| "Cargo metadata has no resolved dependency nodes".to_owned())?;
3995 let mut nodes = HashMap::new();
3996 for value in values {
3997 let id = required_string(value, "id")?;
3998 let dependencies = value
3999 .get("deps")
4000 .and_then(Value::as_array)
4001 .ok_or_else(|| "Cargo metadata dependency node has no deps array".to_owned())?
4002 .iter()
4003 .map(|dependency| required_string(dependency, "pkg"))
4004 .collect::<Result<_, _>>()?;
4005 nodes.insert(
4006 id,
4007 MetadataNode {
4008 dependencies,
4009 value,
4010 },
4011 );
4012 }
4013 Ok(Self {
4014 packages,
4015 workspace_members,
4016 nodes,
4017 workspace_root: metadata.get("workspace_root").and_then(Value::as_str),
4018 })
4019 }
4020}
4021
4022const SEMANTIC_PACKAGE_FIELDS: &[&str] = &[
4023 "authors",
4024 "default_run",
4025 "description",
4026 "documentation",
4027 "edition",
4028 "homepage",
4029 "license",
4030 "license_file",
4031 "links",
4032 "metadata",
4033 "name",
4034 "readme",
4035 "repository",
4036 "rust_version",
4037 "version",
4038];
4039
4040struct LockedPackageIdentity {
4041 name: String,
4042 version: String,
4043 source: String,
4044 checksum: Option<String>,
4045}
4046
4047fn resolve_local_inputs(
4048 spec: &WasmBuildSpec,
4049 metadata: &ParsedCargoMetadata<'_>,
4050) -> Result<(ResolvedLocalInputs, Vec<PathBuf>), WasmBuildError> {
4051 let mut selected_ids = selected_package_ids(spec, metadata)?;
4052 let mut artifact_names = BTreeSet::new();
4055 let wasm_artifact_error = selected_ids.iter().find_map(|id| {
4056 let package = &metadata.packages[id];
4057 if let Some(example) = &spec.example {
4058 return (!package.cdylib_examples.contains(&example.as_str())).then(|| {
4059 format!("Cargo package `{}` must declare the cdylib example `{example}`", package.name)
4060 });
4061 }
4062 let library = default_library_name(package.name);
4063 if package.cdylib_name != Some(library.as_str()) {
4064 Some(format!(
4065 "Cargo package `{}` must declare a cdylib library named `{}` to produce its expected Wasm artifact",
4066 package.name, library,
4067 ))
4068 } else if !artifact_names.insert(library.clone()) {
4069 Some(format!("selected Cargo packages share the Wasm artifact name `{library}`"))
4070 } else {
4071 None
4072 }
4073 });
4074 let mut closure = BTreeSet::new();
4075 while let Some(id) = selected_ids.pop_front() {
4076 if !closure.insert(id) {
4077 continue;
4078 }
4079 if let Some(node) = metadata.nodes.get(id) {
4080 selected_ids.extend(node.dependencies.iter().copied());
4081 }
4082 }
4083
4084 let workspace_root = metadata
4085 .workspace_root
4086 .map_or_else(|| spec.workspace_root.clone(), PathBuf::from);
4087 let workspace_projection = semantic_workspace_projection(metadata, &closure, &workspace_root)?;
4088 let mut validation_inputs = workspace_configuration_inputs(spec, &workspace_root)?;
4089 append_package_inputs(
4090 &mut validation_inputs,
4091 &metadata.packages,
4092 closure,
4093 &workspace_root,
4094 )?;
4095 append_additional_inputs(&mut validation_inputs, spec, &workspace_root);
4096 validate_shared_incremental_target_boundary(spec, &validation_inputs)?;
4097 let exclusions = source_exclusions(spec, &validation_inputs);
4098 Ok((
4099 ResolvedLocalInputs {
4100 validation_inputs,
4101 workspace_projection,
4102 wasm_artifact_error,
4103 },
4104 exclusions,
4105 ))
4106}
4107
4108fn metadata_packages(metadata: &Value) -> Result<HashMap<&str, MetadataPackage<'_>>, String> {
4109 let packages_value = metadata
4110 .get("packages")
4111 .and_then(Value::as_array)
4112 .ok_or_else(|| "Cargo metadata has no package array".to_owned())?;
4113 let mut packages = HashMap::new();
4114 for value in packages_value {
4115 let source = optional_string(value, "source")?;
4116 let package = MetadataPackage {
4117 id: required_string(value, "id")?,
4118 name: required_string(value, "name")?,
4119 version: required_string(value, "version")?,
4120 manifest_path: PathBuf::from(required_string(value, "manifest_path")?),
4121 is_local: value.get("source").is_some_and(Value::is_null),
4122 source,
4123 semantic_fields: SEMANTIC_PACKAGE_FIELDS
4124 .iter()
4125 .map(|field| (*field, value.get(*field).map(Value::to_string)))
4126 .collect(),
4127 cdylib_name: value
4128 .get("targets")
4129 .and_then(Value::as_array)
4130 .and_then(|targets| {
4131 targets.iter().find(|target| {
4132 target
4133 .get("kind")
4134 .and_then(Value::as_array)
4135 .is_some_and(|kinds| kinds.iter().any(|kind| kind == "cdylib"))
4136 })
4137 })
4138 .and_then(|target| target.get("name"))
4139 .and_then(Value::as_str),
4140 cdylib_examples: value
4141 .get("targets")
4142 .and_then(Value::as_array)
4143 .into_iter()
4144 .flatten()
4145 .filter(|target| {
4146 target
4147 .get("kind")
4148 .and_then(Value::as_array)
4149 .is_some_and(|kinds| kinds.iter().any(|kind| kind == "example"))
4150 && target
4151 .get("crate_types")
4152 .and_then(Value::as_array)
4153 .is_some_and(|types| types.iter().any(|kind| kind == "cdylib"))
4154 })
4155 .filter_map(|target| target.get("name").and_then(Value::as_str))
4156 .collect(),
4157 };
4158 packages.insert(package.id, package);
4159 }
4160 Ok(packages)
4161}
4162
4163fn selected_package_ids<'a>(
4164 spec: &WasmBuildSpec,
4165 metadata: &ParsedCargoMetadata<'a>,
4166) -> Result<VecDeque<&'a str>, WasmBuildError> {
4167 let mut selected_ids = VecDeque::new();
4168 for requested in &spec.packages {
4169 let mut matches = metadata
4170 .packages
4171 .values()
4172 .filter(|package| {
4173 package.name == *requested && metadata.workspace_members.contains(package.id)
4174 })
4175 .map(|package| package.id);
4176 match (matches.next(), matches.next()) {
4177 (Some(id), None) => selected_ids.push_back(id),
4178 (None, _) => {
4179 return Err(WasmBuildError::InvalidSpec {
4180 message: format!("Cargo workspace contains no package named `{requested}`"),
4181 });
4182 }
4183 _ => {
4184 return Err(WasmBuildError::InvalidSpec {
4185 message: format!("Cargo workspace package name `{requested}` is ambiguous"),
4186 });
4187 }
4188 }
4189 }
4190 Ok(selected_ids)
4191}
4192
4193fn semantic_workspace_projection(
4194 metadata: &ParsedCargoMetadata<'_>,
4195 closure: &BTreeSet<&str>,
4196 workspace_root: &Path,
4197) -> Result<Option<InputDigest>, WasmBuildError> {
4198 let locked_packages = locked_package_identities(workspace_root)?;
4201 let mut identities = HashMap::new();
4202 for &id in closure {
4203 let package = metadata
4204 .packages
4205 .get(id)
4206 .ok_or_else(|| invalid_metadata(&format!("resolved package `{id}` is missing")))?;
4207 let Some(identity) = semantic_package_identity(package, workspace_root, &locked_packages)
4208 else {
4209 return Ok(None);
4210 };
4211 identities.insert(id, identity);
4212 }
4213
4214 let mut projected_packages = closure
4215 .iter()
4216 .map(|&id| {
4217 let package = metadata
4218 .packages
4219 .get(id)
4220 .expect("selected package closure was validated above");
4221 let identity = identities[id];
4222 let node = metadata.nodes.get(id).ok_or_else(|| {
4223 invalid_metadata(&format!("resolved package `{id}` has no dependency node"))
4224 })?;
4225 let projection = semantic_package_projection(package, node.value, &identities)?;
4226 Ok::<_, WasmBuildError>((identity, projection))
4227 })
4228 .collect::<Result<Vec<_>, _>>()?;
4229 projected_packages.sort_by_key(|(identity, _)| *identity);
4230
4231 let root_manifest = workspace_root.join("Cargo.toml");
4232 let root_contents =
4233 fs::read_to_string(&root_manifest).map_err(|source| WasmBuildError::Io {
4234 operation: "read workspace manifest for semantic projection",
4235 path: root_manifest.clone(),
4236 source,
4237 })?;
4238 let root = toml::from_str::<TomlValue>(&root_contents).map_err(|error| {
4239 invalid_metadata(&format!(
4240 "workspace manifest could not be projected as TOML: {error}"
4241 ))
4242 })?;
4243
4244 let mut hasher = InputHasher::new("wasm-semantic-workspace-projection-v1");
4245 for (identity, projection) in projected_packages {
4246 hasher.field("package-identity", identity.as_bytes());
4247 hasher.field("package-projection", projection.as_bytes());
4248 }
4249 hash_toml_setting(&mut hasher, "cargo-features", root.get("cargo-features"));
4250 hash_toml_setting(&mut hasher, "profile", root.get("profile"));
4251 let workspace = root.get("workspace").and_then(TomlValue::as_table);
4252 hash_toml_setting(
4253 &mut hasher,
4254 "workspace-resolver",
4255 workspace.and_then(|table| table.get("resolver")),
4256 );
4257 hash_toml_setting(
4258 &mut hasher,
4259 "workspace-lints",
4260 workspace.and_then(|table| table.get("lints")),
4261 );
4262 Ok(Some(hasher.finish()))
4263}
4264
4265fn locked_package_identities(
4266 workspace_root: &Path,
4267) -> Result<Vec<LockedPackageIdentity>, WasmBuildError> {
4268 let lockfile = workspace_root.join("Cargo.lock");
4269 let contents = match fs::read_to_string(&lockfile) {
4270 Ok(contents) => contents,
4271 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
4272 Err(source) => {
4273 return Err(WasmBuildError::Io {
4274 operation: "read Cargo lockfile for semantic projection",
4275 path: lockfile,
4276 source,
4277 });
4278 }
4279 };
4280 let lock = toml::from_str::<TomlValue>(&contents).map_err(|error| {
4281 invalid_metadata(&format!(
4282 "Cargo lockfile could not be projected as TOML: {error}"
4283 ))
4284 })?;
4285 let Some(packages) = lock.get("package").and_then(TomlValue::as_array) else {
4286 return Ok(Vec::new());
4287 };
4288 packages
4289 .iter()
4290 .filter_map(|package| {
4291 let Some(table) = package.as_table() else {
4292 return Some(Err(invalid_metadata(
4293 "Cargo lockfile package entry is not a table",
4294 )));
4295 };
4296 let source = table.get("source")?.as_str().map(str::to_owned);
4297 Some(
4298 source
4299 .ok_or_else(|| {
4300 invalid_metadata("Cargo lockfile package source is not a string")
4301 })
4302 .and_then(|source| {
4303 Ok(LockedPackageIdentity {
4304 name: required_toml_string(table, "name", "Cargo lockfile package")?,
4305 version: required_toml_string(
4306 table,
4307 "version",
4308 "Cargo lockfile package",
4309 )?,
4310 source,
4311 checksum: optional_toml_string(
4312 table,
4313 "checksum",
4314 "Cargo lockfile package",
4315 )?,
4316 })
4317 }),
4318 )
4319 })
4320 .collect()
4321}
4322
4323fn required_toml_string(
4324 table: &toml::Table,
4325 field: &str,
4326 context: &str,
4327) -> Result<String, WasmBuildError> {
4328 table
4329 .get(field)
4330 .and_then(TomlValue::as_str)
4331 .map(str::to_owned)
4332 .ok_or_else(|| invalid_metadata(&format!("{context} `{field}` is missing or not a string")))
4333}
4334
4335fn optional_toml_string(
4336 table: &toml::Table,
4337 field: &str,
4338 context: &str,
4339) -> Result<Option<String>, WasmBuildError> {
4340 match table.get(field) {
4341 None => Ok(None),
4342 Some(TomlValue::String(value)) => Ok(Some(value.clone())),
4343 Some(_) => Err(invalid_metadata(&format!(
4344 "{context} `{field}` is not a string"
4345 ))),
4346 }
4347}
4348
4349fn semantic_package_identity(
4350 package: &MetadataPackage<'_>,
4351 workspace_root: &Path,
4352 locked_packages: &[LockedPackageIdentity],
4353) -> Option<InputDigest> {
4354 let mut hasher = InputHasher::new("wasm-semantic-package-identity-v1");
4355 hasher.field("name", package.name.as_bytes());
4356 hasher.field("version", package.version.as_bytes());
4357 if package.is_local {
4358 let manifest = package.manifest_path.strip_prefix(workspace_root).ok()?;
4359 let package_root = package.manifest_path.parent()?;
4360 if package_root == workspace_root {
4361 return None;
4362 }
4363 hasher.field("local-manifest", &os_bytes(manifest.as_os_str()));
4364 } else {
4365 let metadata_source = package.source?;
4366 let locked = locked_packages.iter().find(|locked| {
4367 locked.name == package.name
4368 && locked.version == package.version
4369 && locked.source == metadata_source
4370 })?;
4371 match locked.source.as_str() {
4372 source if source.starts_with("registry+") && locked.checksum.is_some() => {}
4373 source if source.starts_with("git+") && source.contains('#') => {}
4374 _ => return None,
4375 }
4376 hasher.field("external-package-id", package.id.as_bytes());
4377 hasher.field("external-source", locked.source.as_bytes());
4378 hasher.field(
4379 "external-checksum",
4380 locked.checksum.as_deref().unwrap_or_default().as_bytes(),
4381 );
4382 }
4383 Some(hasher.finish())
4384}
4385
4386fn semantic_package_projection(
4387 package: &MetadataPackage<'_>,
4388 node: &Value,
4389 identities: &HashMap<&str, InputDigest>,
4390) -> Result<InputDigest, WasmBuildError> {
4391 let mut hasher = InputHasher::new("wasm-semantic-package-projection-v1");
4395 for (field, value) in &package.semantic_fields {
4396 hasher.field("package-field-name", field.as_bytes());
4397 match value {
4398 Some(value) => hasher.field("package-field-value", value.as_bytes()),
4399 None => hasher.field("package-field-missing", b""),
4400 }
4401 }
4402
4403 let mut features = node
4404 .get("features")
4405 .and_then(Value::as_array)
4406 .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no features array"))?
4407 .iter()
4408 .map(|feature| {
4409 feature.as_str().ok_or_else(|| {
4410 invalid_metadata("Cargo metadata dependency feature is not a string")
4411 })
4412 })
4413 .collect::<Result<Vec<_>, _>>()?;
4414 features.sort_unstable();
4415 for feature in features {
4416 hasher.field("enabled-feature", feature.as_bytes());
4417 }
4418
4419 let mut dependencies = node
4420 .get("deps")
4421 .and_then(Value::as_array)
4422 .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
4423 .iter()
4424 .map(|dependency| {
4425 let name = required_string(dependency, "name")
4426 .map_err(|message| invalid_metadata(&message))?;
4427 let package_id =
4428 required_string(dependency, "pkg").map_err(|message| invalid_metadata(&message))?;
4429 let identity = identities.get(package_id).copied().ok_or_else(|| {
4430 invalid_metadata(&format!(
4431 "dependency `{package_id}` is outside the selected package closure"
4432 ))
4433 })?;
4434 let kinds = dependency
4435 .get("dep_kinds")
4436 .ok_or_else(|| invalid_metadata("Cargo metadata dependency has no kind array"))?
4437 .to_string();
4438 Ok::<_, WasmBuildError>((name, identity, kinds))
4439 })
4440 .collect::<Result<Vec<_>, _>>()?;
4441 dependencies.sort();
4442 for (name, identity, kinds) in dependencies {
4443 hasher.field("dependency-name", name.as_bytes());
4444 hasher.field("dependency-identity", identity.as_bytes());
4445 hasher.field("dependency-kinds", kinds.as_bytes());
4446 }
4447 Ok(hasher.finish())
4448}
4449
4450fn hash_toml_setting(hasher: &mut InputHasher, label: &str, value: Option<&TomlValue>) {
4451 hasher.field("workspace-setting-name", label.as_bytes());
4452 match value {
4453 Some(value) => hasher.field("workspace-setting-value", value.to_string().as_bytes()),
4454 None => hasher.field("workspace-setting-missing", b""),
4455 }
4456}
4457
4458fn is_broad_workspace_input(label: &Path) -> bool {
4459 label == Path::new("workspace/Cargo.toml") || label == Path::new("workspace/Cargo.lock")
4460}
4461
4462fn digest_resolved_local_inputs(
4463 inputs: &ResolvedLocalInputs,
4464 exclusions: &[PathBuf],
4465 cache: &mut LabeledPathDigestCache,
4466 error_path: &Path,
4467 validation_operation: &'static str,
4468 semantic_operation: &'static str,
4469) -> Result<(InputDigest, InputDigest), WasmBuildError> {
4470 let validation_digest = digest_labeled_paths_composable(
4471 "wasm-source-inputs-v1",
4472 inputs
4473 .validation_inputs
4474 .iter()
4475 .map(|(label, path)| (label.as_path(), path.as_path())),
4476 exclusions,
4477 cache,
4478 )
4479 .map_err(|source| WasmBuildError::Io {
4480 operation: validation_operation,
4481 path: error_path.to_owned(),
4482 source,
4483 })?;
4484 let input_digest = semantic_input_digest(inputs, validation_digest, exclusions, cache)
4485 .map_err(|source| WasmBuildError::Io {
4486 operation: semantic_operation,
4487 path: error_path.to_owned(),
4488 source,
4489 })?;
4490 Ok((input_digest, validation_digest))
4491}
4492
4493fn semantic_input_digest(
4494 inputs: &ResolvedLocalInputs,
4495 validation_digest: InputDigest,
4496 exclusions: &[PathBuf],
4497 cache: &mut LabeledPathDigestCache,
4498) -> io::Result<InputDigest> {
4499 let Some(workspace) = inputs.workspace_projection else {
4500 return Ok(validation_digest);
4501 };
4502 let path_digest = digest_labeled_paths_composable(
4503 "wasm-source-inputs-v1",
4504 inputs
4505 .validation_inputs
4506 .iter()
4507 .filter(|(label, _)| !is_broad_workspace_input(label))
4508 .map(|(label, path)| (label.as_path(), path.as_path())),
4509 exclusions,
4510 cache,
4511 )?;
4512 let mut hasher = InputHasher::new("wasm-semantic-source-inputs-v1");
4513 hasher.field("path-input-digest", path_digest.as_bytes());
4514 hasher.field("workspace-projection", workspace.as_bytes());
4515 Ok(hasher.finish())
4516}
4517
4518fn workspace_configuration_inputs(
4519 spec: &WasmBuildSpec,
4520 workspace_root: &Path,
4521) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
4522 let mut inputs = Vec::new();
4523 add_if_present(
4524 &mut inputs,
4525 "workspace/Cargo.toml",
4526 workspace_root.join("Cargo.toml"),
4527 );
4528 add_if_present(
4529 &mut inputs,
4530 "workspace/Cargo.lock",
4531 workspace_root.join("Cargo.lock"),
4532 );
4533 add_if_present(
4534 &mut inputs,
4535 "workspace/rust-toolchain.toml",
4536 workspace_root.join("rust-toolchain.toml"),
4537 );
4538 add_if_present(
4539 &mut inputs,
4540 "workspace/rust-toolchain",
4541 workspace_root.join("rust-toolchain"),
4542 );
4543 append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
4544 Ok(inputs)
4545}
4546
4547fn append_cargo_configuration_inputs(
4548 inputs: &mut Vec<(PathBuf, PathBuf)>,
4549 spec: &WasmBuildSpec,
4550 workspace_root: &Path,
4551) -> Result<(), WasmBuildError> {
4552 let invocation_root =
4553 spec.workspace_root
4554 .canonicalize()
4555 .map_err(|source| WasmBuildError::Io {
4556 operation: "resolve Cargo invocation directory",
4557 path: spec.workspace_root.clone(),
4558 source,
4559 })?;
4560 let canonical_workspace =
4561 workspace_root
4562 .canonicalize()
4563 .map_err(|source| WasmBuildError::Io {
4564 operation: "resolve Cargo workspace directory",
4565 path: workspace_root.to_owned(),
4566 source,
4567 })?;
4568
4569 let mut roots = invocation_root
4570 .ancestors()
4571 .filter_map(|directory| effective_cargo_config(&directory.join(".cargo")))
4572 .collect::<Vec<_>>();
4573 if let Some(cargo_home) = effective_cargo_home(spec, &invocation_root)
4574 && let Some(config) = effective_cargo_config(&cargo_home)
4575 {
4576 roots.push(config);
4577 }
4578
4579 let mut active = BTreeSet::new();
4580 for config in roots {
4581 append_cargo_configuration_tree(inputs, &config, &canonical_workspace, &mut active, false)?;
4582 }
4583 Ok(())
4584}
4585
4586fn effective_cargo_config(directory: &Path) -> Option<PathBuf> {
4587 let extensionless = directory.join("config");
4588 if extensionless.exists() {
4589 return Some(extensionless);
4590 }
4591 let toml = directory.join("config.toml");
4592 toml.exists().then_some(toml)
4593}
4594
4595fn effective_cargo_home(spec: &WasmBuildSpec, invocation_root: &Path) -> Option<PathBuf> {
4596 if let Some(cargo_home) = command_environment_value(spec, "CARGO_HOME") {
4597 let cargo_home = PathBuf::from(cargo_home);
4598 return Some(if cargo_home.is_absolute() {
4599 cargo_home
4600 } else {
4601 invocation_root.join(cargo_home)
4602 });
4603 }
4604
4605 default_home_directory(spec).map(|home| {
4606 let home = if home.is_absolute() {
4607 home
4608 } else {
4609 invocation_root.join(home)
4610 };
4611 home.join(".cargo")
4612 })
4613}
4614
4615#[cfg(windows)]
4616fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4617 command_environment_value(spec, "USERPROFILE")
4618 .or_else(|| command_environment_value(spec, "HOME"))
4619 .map(PathBuf::from)
4620}
4621
4622#[cfg(not(windows))]
4623fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4624 command_environment_value(spec, "HOME").map(PathBuf::from)
4625}
4626
4627fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
4628 spec.extra_env
4629 .get(OsStr::new(name))
4630 .cloned()
4631 .or_else(|| std::env::var_os(name))
4632}
4633
4634fn append_cargo_configuration_tree(
4635 inputs: &mut Vec<(PathBuf, PathBuf)>,
4636 config: &Path,
4637 workspace_root: &Path,
4638 active: &mut BTreeSet<PathBuf>,
4639 optional: bool,
4640) -> Result<(), WasmBuildError> {
4641 let contents = match fs::read_to_string(config) {
4642 Ok(contents) => contents,
4643 Err(error) if optional && error.kind() == io::ErrorKind::NotFound => return Ok(()),
4644 Err(source) => {
4645 return Err(WasmBuildError::Io {
4646 operation: "read Cargo configuration",
4647 path: config.to_owned(),
4648 source,
4649 });
4650 }
4651 };
4652 let parent = config
4653 .parent()
4654 .ok_or_else(|| WasmBuildError::InvalidCargoConfiguration {
4655 path: config.to_owned(),
4656 message: "configuration path has no parent directory".to_owned(),
4657 })?;
4658 let location = parent
4661 .canonicalize()
4662 .map_err(|source| WasmBuildError::Io {
4663 operation: "resolve Cargo configuration directory",
4664 path: parent.to_owned(),
4665 source,
4666 })?
4667 .join(config.file_name().ok_or_else(|| {
4668 invalid_cargo_configuration(config, "configuration path has no file name")
4669 })?);
4670 if !active.insert(location.clone()) {
4673 return Ok(());
4674 }
4675 let configuration = toml::from_str::<TomlValue>(&contents).map_err(|error| {
4676 WasmBuildError::InvalidCargoConfiguration {
4677 path: config.to_owned(),
4678 message: error.to_string(),
4679 }
4680 })?;
4681 if !inputs.iter().any(|(_, path)| path == config) {
4684 inputs.push((
4685 cargo_configuration_label(&location, workspace_root),
4686 config.to_owned(),
4687 ));
4688 }
4689 if let Some(include) = configuration.get("include") {
4690 for (included, optional) in cargo_configuration_includes(include, config)? {
4691 let included = if included.is_absolute() {
4692 included
4693 } else {
4694 parent.join(included)
4695 };
4696 append_cargo_configuration_tree(inputs, &included, workspace_root, active, optional)?;
4697 }
4698 }
4699 active.remove(&location);
4700 Ok(())
4701}
4702
4703fn cargo_configuration_includes(
4704 include: &TomlValue,
4705 config: &Path,
4706) -> Result<Vec<(PathBuf, bool)>, WasmBuildError> {
4707 let values = match include {
4708 TomlValue::Array(values) => values.as_slice(),
4709 value => std::slice::from_ref(value),
4710 };
4711 values
4712 .iter()
4713 .map(|value| match value {
4714 TomlValue::String(path) => Ok((PathBuf::from(path), false)),
4715 TomlValue::Table(table) => {
4716 let path = table
4717 .get("path")
4718 .and_then(TomlValue::as_str)
4719 .ok_or_else(|| {
4720 invalid_cargo_configuration(
4721 config,
4722 "Cargo configuration include table requires a string `path`",
4723 )
4724 })?;
4725 let optional = table
4726 .get("optional")
4727 .map(|value| {
4728 value.as_bool().ok_or_else(|| {
4729 invalid_cargo_configuration(
4730 config,
4731 "Cargo configuration include `optional` must be a boolean",
4732 )
4733 })
4734 })
4735 .transpose()?
4736 .unwrap_or(false);
4737 Ok((PathBuf::from(path), optional))
4738 }
4739 _ => Err(invalid_cargo_configuration(
4740 config,
4741 "Cargo configuration `include` must contain paths or include tables",
4742 )),
4743 })
4744 .collect()
4745}
4746
4747fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
4748 if let Ok(relative) = config.strip_prefix(workspace_root) {
4749 return PathBuf::from("cargo-config/workspace").join(relative);
4750 }
4751 let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
4752 PathBuf::from("cargo-config/external").join(location.to_hex())
4753}
4754
4755fn invalid_cargo_configuration(path: &Path, message: &str) -> WasmBuildError {
4756 WasmBuildError::InvalidCargoConfiguration {
4757 path: path.to_owned(),
4758 message: message.to_owned(),
4759 }
4760}
4761
4762fn append_package_inputs(
4763 inputs: &mut Vec<(PathBuf, PathBuf)>,
4764 packages: &HashMap<&str, MetadataPackage<'_>>,
4765 closure: BTreeSet<&str>,
4766 workspace_root: &Path,
4767) -> Result<(), WasmBuildError> {
4768 for id in closure {
4769 let Some(package) = packages.get(id) else {
4770 return Err(invalid_metadata(&format!(
4771 "resolved package `{id}` is missing"
4772 )));
4773 };
4774 if !package.is_local {
4775 continue;
4776 }
4777 let root = package.manifest_path.parent().ok_or_else(|| {
4778 invalid_metadata(&format!(
4779 "package `{}` manifest has no parent",
4780 package.name
4781 ))
4782 })?;
4783 let relative_manifest = package
4784 .manifest_path
4785 .strip_prefix(workspace_root)
4786 .unwrap_or(&package.manifest_path);
4787 let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
4788 .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
4789 inputs.push((label, root.to_owned()));
4790 }
4791 Ok(())
4792}
4793
4794fn append_additional_inputs(
4795 inputs: &mut Vec<(PathBuf, PathBuf)>,
4796 spec: &WasmBuildSpec,
4797 workspace_root: &Path,
4798) {
4799 for additional in &spec.additional_inputs {
4800 let path = if additional.is_absolute() {
4801 additional.clone()
4802 } else {
4803 workspace_root.join(additional)
4804 };
4805 inputs.push((PathBuf::from("additional").join(additional), path));
4806 }
4807}
4808
4809fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
4810 let mut exclusions = vec![
4811 spec.target_dir.clone(),
4812 spec.workspace_root.join("target"),
4813 spec.workspace_root.join(".git"),
4814 ];
4815 if let Some(shared_target) = shared_incremental_target(spec) {
4816 exclusions.push(shared_target);
4817 }
4818 for (_, path) in inputs {
4819 if path.is_dir() {
4820 exclusions.push(path.join("target"));
4821 exclusions.push(path.join(".git"));
4822 }
4823 }
4824 exclusions
4825}
4826
4827fn validate_shared_incremental_target_boundary(
4828 spec: &WasmBuildSpec,
4829 inputs: &[(PathBuf, PathBuf)],
4830) -> Result<(), WasmBuildError> {
4831 let Some(shared_target) = shared_incremental_target(spec) else {
4832 return Ok(());
4833 };
4834 let shared_target =
4835 canonicalize_allow_missing(&shared_target).map_err(|source| WasmBuildError::Io {
4836 operation: "resolve shared incremental Cargo target boundary",
4837 path: shared_target.clone(),
4838 source,
4839 })?;
4840 let exact_entries = spec.target_dir.join(".ic-testkit/wasm-targets");
4841 let exact_entries =
4842 canonicalize_allow_missing(&exact_entries).map_err(|source| WasmBuildError::Io {
4843 operation: "resolve exact Wasm cache boundary",
4844 path: exact_entries,
4845 source,
4846 })?;
4847 if shared_target.starts_with(&exact_entries)
4850 || (exact_entries.starts_with(&shared_target)
4851 && !exact_entries.starts_with(shared_target.join(".ic-testkit")))
4852 {
4853 return Err(WasmBuildError::InvalidSpec {
4854 message: "shared incremental target must not overlap removable exact Wasm cache state"
4855 .to_owned(),
4856 });
4857 }
4858 let resolved_inputs = inputs
4859 .iter()
4860 .map(|(_, input)| {
4861 let canonical = input.canonicalize().map_err(|source| WasmBuildError::Io {
4862 operation: "resolve Cargo input boundary",
4863 path: input.clone(),
4864 source,
4865 })?;
4866 let metadata = fs::metadata(&canonical).map_err(|source| WasmBuildError::Io {
4867 operation: "inspect Cargo input boundary",
4868 path: canonical.clone(),
4869 source,
4870 })?;
4871 Ok((canonical, metadata.is_dir()))
4872 })
4873 .collect::<Result<Vec<_>, WasmBuildError>>()?;
4874 let safe_generated_roots = std::iter::once(spec.target_dir.clone())
4875 .chain(std::iter::once(spec.workspace_root.join("target")))
4876 .chain(
4877 inputs
4878 .iter()
4879 .filter(|(_, path)| path.is_dir())
4880 .map(|(_, path)| path.join("target")),
4881 )
4882 .filter_map(|path| canonicalize_allow_missing(&path).ok())
4883 .filter(|root| {
4884 !resolved_inputs
4885 .iter()
4886 .any(|(input, _is_directory)| input.starts_with(root))
4887 })
4888 .collect::<Vec<_>>();
4889 if safe_generated_roots
4890 .iter()
4891 .any(|root| shared_target.starts_with(root))
4892 {
4893 return Ok(());
4894 }
4895
4896 for (input, is_directory) in resolved_inputs {
4897 if shared_target == input
4898 || (is_directory && shared_target.starts_with(&input))
4899 || input.starts_with(&shared_target)
4900 {
4901 return Err(WasmBuildError::InvalidSpec {
4902 message: format!(
4903 "shared incremental target {} must not overlap exact Cargo inputs unless it is inside a generated target directory",
4904 shared_target.display()
4905 ),
4906 });
4907 }
4908 }
4909 Ok(())
4910}
4911
4912pub(super) fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
4913 let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
4914 return None;
4915 };
4916 Some(if target_dir.is_absolute() {
4917 target_dir.clone()
4918 } else {
4919 spec.workspace_root.join(target_dir)
4920 })
4921}
4922
4923fn shared_incremental_target_exists(
4924 spec: &WasmBuildSpec,
4925 operation: &'static str,
4926) -> Result<bool, WasmBuildError> {
4927 let target_dir =
4928 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
4929 message: "shared incremental target is not configured".to_owned(),
4930 })?;
4931 match fs::symlink_metadata(&target_dir) {
4932 Ok(metadata) if metadata.is_dir() => Ok(true),
4933 Ok(_) => Err(WasmBuildError::InvalidSpec {
4934 message: format!(
4935 "shared incremental Cargo target {} must be a directory",
4936 target_dir.display()
4937 ),
4938 }),
4939 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
4940 Err(source) => Err(WasmBuildError::Io {
4941 operation,
4942 path: target_dir,
4943 source,
4944 }),
4945 }
4946}
4947
4948fn effective_environment(spec: &WasmBuildSpec) -> BTreeMap<OsString, Option<OsString>> {
4949 let mut names = spec.inherited_env.clone();
4950 names.extend(AUTOMATIC_ENVIRONMENT.iter().map(OsString::from));
4951 let mut environment = names
4952 .into_iter()
4953 .map(|name| {
4954 let value = std::env::var_os(&name);
4955 (name, value)
4956 })
4957 .collect::<BTreeMap<_, _>>();
4958 for (key, value) in &spec.extra_env {
4959 environment.insert(key.clone(), Some(value.clone()));
4960 }
4961 environment
4962}
4963
4964fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
4965 for (key, value) in &spec.extra_env {
4966 command.env(key, value);
4967 }
4968}
4969
4970fn run_cargo_build(
4971 spec: &WasmBuildSpec,
4972 build_target_dir: &Path,
4973 progress: &mut ProgressReporter<'_>,
4974) -> Result<(), WasmBuildError> {
4975 let command = cargo_build_command(spec, build_target_dir)?;
4976 communicate_cargo_build(spec, build_target_dir, command, progress)
4977}
4978
4979fn cargo_build_command(
4980 spec: &WasmBuildSpec,
4981 build_target_dir: &Path,
4982) -> Result<Command, WasmBuildError> {
4983 let absolute_target_dir =
4984 canonicalize_allow_missing(build_target_dir).map_err(|source| WasmBuildError::Io {
4985 operation: "resolve Cargo build target directory",
4986 path: build_target_dir.to_owned(),
4987 source,
4988 })?;
4989 let mut command = Command::new(&spec.cargo_program);
4990 command
4991 .current_dir(&spec.workspace_root)
4992 .env("CARGO_TARGET_DIR", absolute_target_dir)
4993 .arg("build");
4994 if let Some(example) = &spec.example {
4995 command.args(["--example", example]);
4996 } else {
4997 command.arg("--lib");
4998 }
4999 command.args(["--target", &spec.target]).args(
5000 spec.cargo_profile_args
5001 .iter()
5002 .filter(|argument| argument.as_os_str() != OsStr::new("--lib")),
5003 );
5004 apply_command_environment(&mut command, spec);
5005 for package in &spec.packages {
5006 command.args(["-p", package]);
5007 }
5008
5009 Ok(command)
5010}
5011
5012fn communicate_cargo_build(
5013 spec: &WasmBuildSpec,
5014 build_target_dir: &Path,
5015 mut command: Command,
5016 progress: &mut ProgressReporter<'_>,
5017) -> Result<(), WasmBuildError> {
5018 command.stdout(Stdio::piped()).stderr(Stdio::piped());
5021 let started = Instant::now();
5022 let observed = progress.is_observed();
5023 if !observed {
5024 command.stdin(Stdio::null());
5026 }
5027 let mut child = if observed {
5028 OwnedChild::spawn(&mut command)
5029 } else {
5030 OwnedChild::spawn_direct(&mut command)
5031 }
5032 .map_err(|source| WasmBuildError::CommandSpawn {
5033 phase: WasmBuildPhase::CargoBuild,
5034 program: spec.cargo_program.clone(),
5035 source,
5036 })?;
5037 progress.emit(WasmBuildProgressEvent::CargoStarted {
5038 target_dir: build_target_dir.to_owned(),
5039 });
5040 let progress = RefCell::new(progress);
5041 let result = communicate_child_with_observer(
5042 &mut child,
5043 None,
5044 OutputLimits {
5045 stdout: OutputLimit::Truncate(CARGO_DIAGNOSTIC_BYTES),
5048 stderr: OutputLimit::Truncate(CARGO_DIAGNOSTIC_BYTES),
5049 timeout: None,
5050 },
5051 SuccessfulExit::Cleanup,
5052 || {
5053 progress
5054 .borrow_mut()
5055 .emit_heartbeat_if_due(WasmBuildProgressPhase::CargoBuild, started.elapsed());
5056 false
5057 },
5058 |stream, bytes| {
5059 let mut progress = progress.borrow_mut();
5060 if progress.config.emit_cargo_output {
5061 progress.emit(WasmBuildProgressEvent::CargoOutput {
5062 stream: match stream {
5063 OutputStream::Stdout => WasmBuildOutputStream::Stdout,
5064 OutputStream::Stderr => WasmBuildOutputStream::Stderr,
5065 },
5066 bytes: bytes.to_vec(),
5067 });
5068 }
5069 Ok(())
5070 },
5071 );
5072 let status = match &result {
5073 Ok(evidence) => Some(evidence.status),
5074 Err(error) => error.evidence().and_then(|evidence| evidence.status),
5075 };
5076 if let Some(status) = status {
5077 progress
5078 .borrow_mut()
5079 .emit(WasmBuildProgressEvent::CargoFinished {
5080 success: status.success(),
5081 code: status.code(),
5082 elapsed: started.elapsed(),
5083 });
5084 }
5085 let evidence = match result {
5086 Ok(evidence) => evidence,
5087 Err(error) => {
5088 return Err(WasmBuildError::Io {
5089 operation: "communicate with cargo build",
5090 path: PathBuf::from(&spec.cargo_program),
5091 source: io::Error::other(error),
5093 });
5094 }
5095 };
5096 let status = evidence.status;
5097 if status.success() {
5098 return Ok(());
5099 }
5100 Err(WasmBuildError::CommandFailed {
5101 phase: WasmBuildPhase::CargoBuild,
5102 status,
5103 stdout: cargo_diagnostic(&evidence.stdout, evidence.stdout_truncated),
5104 stderr: cargo_diagnostic(&evidence.stderr, evidence.stderr_truncated),
5105 })
5106}
5107
5108fn cargo_diagnostic(bytes: &[u8], truncated: bool) -> String {
5109 let mut text = String::from_utf8_lossy(bytes).into_owned();
5110 if truncated {
5111 text.push_str("\n[diagnostic truncated after 1 MiB; enable Cargo output events for the complete stream]\n");
5112 }
5113 text
5114}
5115
5116fn ensure_command_success(
5117 phase: WasmBuildPhase,
5118 output: CompletedExecution,
5119) -> Result<CompletedExecution, WasmBuildError> {
5120 if output.status.success() {
5121 return Ok(output);
5122 }
5123 Err(WasmBuildError::CommandFailed {
5124 phase,
5125 status: output.status,
5126 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
5127 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
5128 })
5129}
5130
5131fn default_library_name(package: &str) -> String {
5132 package.replace('-', "_")
5133}
5134
5135fn expected_artifacts(spec: &WasmBuildSpec, target_dir: &Path) -> Vec<PathBuf> {
5136 spec.packages
5137 .iter()
5138 .map(|package| {
5139 let profile = target_dir.join(&spec.target).join(&spec.profile_target_dir);
5140 spec.example.as_ref().map_or_else(
5141 || profile.join(format!("{}.wasm", default_library_name(package))),
5142 |example| {
5143 profile
5144 .join("examples")
5145 .join(format!("{}.wasm", default_library_name(example)))
5146 },
5147 )
5148 })
5149 .collect()
5150}
5151
5152fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
5153 spec.target_dir
5154 .join(".ic-testkit/wasm-targets")
5155 .join(fingerprint.to_hex())
5156}
5157
5158fn validated_artifact_set(
5159 artifacts: &[PathBuf],
5160 fingerprint: InputDigest,
5161) -> Option<Vec<FileDigest>> {
5162 let header = artifact_stamp_header(fingerprint);
5163 let stamp_len = artifact_stamp_contents(fingerprint, fingerprint).len();
5166 artifacts
5167 .iter()
5168 .map(|artifact| {
5169 let metadata = fs::metadata(artifact).ok()?;
5170 if !metadata.is_file() || metadata.len() == 0 {
5171 return None;
5172 }
5173 let stamp = read_stamp_with_limit(&artifact_stamp_path(artifact), stamp_len).ok()??;
5174 if stamp.len() != stamp_len || !stamp.starts_with(&header) {
5177 return None;
5178 }
5179 let info = digest_file("wasm-artifact-v1", artifact).ok()?;
5180 (stamp == artifact_stamp_contents(fingerprint, info.digest)).then_some(info)
5181 })
5182 .collect()
5183}
5184
5185fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
5186 artifacts
5187 .iter()
5188 .filter(|path| {
5189 fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
5190 })
5191 .cloned()
5192 .collect()
5193}
5194
5195fn artifact_stamp_path(artifact: &Path) -> PathBuf {
5196 let mut name = artifact
5197 .file_name()
5198 .map_or_else(|| OsString::from("artifact"), OsString::from);
5199 name.push(".ic-testkit-build");
5200 artifact.with_file_name(name)
5201}
5202
5203fn artifact_stamp_header(fingerprint: InputDigest) -> String {
5204 format!("{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\n")
5205}
5206
5207fn artifact_stamp_contents(fingerprint: InputDigest, artifact_digest: InputDigest) -> String {
5208 format!(
5209 "{}artifact-sha256:{artifact_digest}\n",
5210 artifact_stamp_header(fingerprint),
5211 )
5212}
5213
5214fn write_artifact_stamp(
5215 artifact: &Path,
5216 fingerprint: InputDigest,
5217 info: &FileDigest,
5218 preserve_matching: bool,
5219) -> Result<(), WasmBuildError> {
5220 let stamp_path = artifact_stamp_path(artifact);
5221 let stamp = artifact_stamp_contents(fingerprint, info.digest);
5222 if preserve_matching && destination_matches_bytes(&stamp_path, stamp.as_bytes()) {
5223 return Ok(());
5224 }
5225 ic_host_fs::durable::write_bytes(&stamp_path, stamp.as_bytes()).map_err(|source| {
5226 WasmBuildError::Io {
5227 operation: "publish Wasm build stamp",
5228 path: stamp_path,
5229 source: io::Error::other(source),
5230 }
5231 })
5232}
5233
5234fn publish_artifact_stamps(
5235 artifacts: &[PathBuf],
5236 fingerprint: InputDigest,
5237) -> Result<Vec<FileDigest>, WasmBuildError> {
5238 let mut info = Vec::with_capacity(artifacts.len());
5239 for artifact in artifacts {
5240 let digest =
5241 digest_file("wasm-artifact-v1", artifact).map_err(|source| WasmBuildError::Io {
5242 operation: "hash built Wasm artifact",
5243 path: artifact.clone(),
5244 source,
5245 })?;
5246 write_artifact_stamp(artifact, fingerprint, &digest, false)?;
5247 info.push(digest);
5248 }
5249 Ok(info)
5250}
5251
5252fn materialize_artifacts(
5253 cached_artifacts: &[PathBuf],
5254 artifacts: &[PathBuf],
5255 fingerprint: InputDigest,
5256 artifact_info: &[FileDigest],
5257 preserve_matching: bool,
5258) -> Result<(), WasmBuildError> {
5259 for ((cached, artifact), info) in cached_artifacts.iter().zip(artifacts).zip(artifact_info) {
5260 if preserve_matching && destination_matches_digest("wasm-artifact-v1", artifact, info) {
5261 continue;
5262 }
5263 copy_file_atomic(cached, artifact).map_err(|source| WasmBuildError::Io {
5264 operation: "publish Wasm artifact",
5265 path: artifact.clone(),
5266 source,
5267 })?;
5268 }
5269 for (artifact, info) in artifacts.iter().zip(artifact_info) {
5272 write_artifact_stamp(artifact, fingerprint, info, preserve_matching)?;
5273 }
5274 Ok(())
5275}
5276
5277fn copy_wasm_artifacts(
5278 source_artifacts: &[PathBuf],
5279 cached_artifacts: &[PathBuf],
5280) -> Result<(), WasmBuildError> {
5281 for (source, cached) in source_artifacts.iter().zip(cached_artifacts) {
5282 copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
5283 operation: "cache shared-incremental Wasm artifact",
5284 path: cached.clone(),
5285 source: source_error,
5286 })?;
5287 }
5288 Ok(())
5289}
5290
5291fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
5292 fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
5293 operation,
5294 path: path.to_owned(),
5295 source,
5296 })
5297}
5298
5299fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
5300 if path.exists() {
5301 inputs.push((PathBuf::from(label), path));
5302 }
5303}
5304
5305fn required_string<'a>(value: &'a Value, field: &str) -> Result<&'a str, String> {
5306 value
5307 .get(field)
5308 .and_then(Value::as_str)
5309 .ok_or_else(|| format!("Cargo metadata field `{field}` is missing"))
5310}
5311
5312fn optional_string<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, String> {
5313 match value.get(field) {
5314 None | Some(Value::Null) => Ok(None),
5315 Some(Value::String(value)) => Ok(Some(value)),
5316 Some(_) => Err(format!(
5317 "Cargo metadata field `{field}` is not a string or null"
5318 )),
5319 }
5320}
5321
5322fn invalid_metadata(message: &str) -> WasmBuildError {
5323 WasmBuildError::InvalidMetadata {
5324 message: message.to_owned(),
5325 }
5326}
5327
5328impl WasmBuildError {
5329 fn indicates_input_change(&self) -> bool {
5330 match self {
5331 Self::InputsChangedDuringAcquisition { .. } => true,
5332 Self::FailedBuildCleanup { build_error, .. } => build_error.indicates_input_change(),
5333 _ => false,
5334 }
5335 }
5336}
5337
5338impl std::fmt::Display for WasmBuildPhase {
5339 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5340 formatter.write_str(match self {
5341 Self::CargoMetadata => "cargo metadata",
5342 Self::CargoIdentity => "Cargo identity",
5343 Self::RustcIdentity => "Rust compiler identity",
5344 Self::CargoBuild => "cargo build",
5345 })
5346 }
5347}
5348
5349impl std::fmt::Display for WasmBuildProgressPhase {
5350 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5351 formatter.write_str(match self {
5352 Self::ExactCacheLock => "exact cache lock",
5353 Self::CargoIdentity => "Cargo identity",
5354 Self::RustcIdentity => "Rust compiler identity",
5355 Self::CargoMetadata => "Cargo metadata",
5356 Self::InputDiscovery => "input discovery",
5357 Self::ContentHashing => "content hashing",
5358 Self::SharedTargetLock => "shared target lock",
5359 Self::SharedTargetMaintenance => "shared target maintenance",
5360 Self::CargoBuild => "Cargo build",
5361 Self::ArtifactPublication => "artifact publication",
5362 Self::ExactCacheMaintenance => "exact cache maintenance",
5363 })
5364 }
5365}
5366
5367impl std::fmt::Display for WasmBuildError {
5368 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5369 match self {
5370 Self::InvalidSpec { message } => {
5371 write!(formatter, "invalid Wasm build spec: {message}")
5372 }
5373 Self::Io {
5374 operation,
5375 path,
5376 source,
5377 } => write!(
5378 formatter,
5379 "failed to {operation} at {}: {source}",
5380 path.display()
5381 ),
5382 Self::CommandSpawn {
5383 phase,
5384 program,
5385 source,
5386 } => write!(
5387 formatter,
5388 "failed to launch {phase} using `{}`: {source}",
5389 program.to_string_lossy(),
5390 ),
5391 Self::CommandFailed {
5392 phase,
5393 status,
5394 stdout,
5395 stderr,
5396 } => write!(
5397 formatter,
5398 "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
5399 ),
5400 Self::InvalidMetadata { message } => {
5401 write!(formatter, "invalid Cargo metadata: {message}")
5402 }
5403 Self::InvalidCargoConfiguration { path, message } => write!(
5404 formatter,
5405 "invalid Cargo configuration at {}: {message}",
5406 path.display(),
5407 ),
5408 Self::MissingArtifacts { paths } => write!(
5409 formatter,
5410 "cargo build succeeded without producing: {}",
5411 paths
5412 .iter()
5413 .map(|path| path.display().to_string())
5414 .collect::<Vec<_>>()
5415 .join(", "),
5416 ),
5417 Self::InputsChangedDuringAcquisition { before, after } => write!(
5418 formatter,
5419 "Wasm inputs changed during artifact acquisition: {before} -> {after}",
5420 ),
5421 Self::PreparedInputSnapshotInvalidated => formatter.write_str(
5422 "the prepared Wasm input snapshot was invalidated before artifact publication",
5423 ),
5424 Self::FailedBuildCleanup {
5425 build_error,
5426 path,
5427 source,
5428 } => write!(
5429 formatter,
5430 "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
5431 path.display(),
5432 ),
5433 }
5434 }
5435}
5436
5437impl std::error::Error for WasmBuildError {
5438 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
5439 match self {
5440 Self::Io { source, .. }
5441 | Self::CommandSpawn { source, .. }
5442 | Self::FailedBuildCleanup { source, .. } => Some(source),
5443 _ => None,
5444 }
5445 }
5446}
5447
5448#[cfg(test)]
5449mod tests;