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