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