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