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 match result {
3011 Ok(()) => {
3012 incomplete.preserve();
3013 Ok(())
3014 }
3015 Err(build_error) => Err(cleanup_failed_fingerprint_build(
3016 build_error,
3017 incomplete,
3018 progress,
3019 )),
3020 }
3021}
3022
3023fn build_wasm_cache_miss(
3024 spec: &WasmBuildSpec,
3025 resolved: ResolvedCargoBuildInputs,
3026 lock_wait: Duration,
3027 shared_incremental: WasmAcquisitionContext,
3028 cargo_target_dir: PathBuf,
3029 total_started: Instant,
3030 progress: &mut ProgressReporter<'_>,
3031) -> Result<WasmBuildOutcome, WasmBuildError> {
3032 let fingerprint = resolved.fingerprint;
3033 let mut input_resolution = resolved.timings;
3034 let artifacts = expected_artifacts(spec, &spec.target_dir);
3035 let cache_entry = cache_entry_directory(spec, fingerprint);
3036 let preparation_started = Instant::now();
3037 progress.begin_phase(WasmBuildFailurePhase::ArtifactPublication);
3038 let preparation_result = (|| {
3039 remove_unretained_entry(&cache_entry).map_err(wasm_cache_fs_error)?;
3040 create_dir_all(
3041 &cache_entry,
3042 "create content-addressed Cargo target directory",
3043 )
3044 })();
3045 progress.record_phase(
3046 WasmBuildFailurePhase::ArtifactPublication,
3047 preparation_started.elapsed(),
3048 );
3049 preparation_result?;
3050 let incomplete_directory = IncompleteBuildDirectory::new(cache_entry.clone());
3051 let build_result = (|| {
3052 if matches!(
3053 spec.cache_mode,
3054 WasmBuildCacheMode::SharedIncremental { .. }
3055 ) {
3056 record_cache_entry_use(&cargo_target_dir)?;
3057 }
3058 let build_started = Instant::now();
3059 progress.begin_phase(WasmBuildFailurePhase::CargoBuild);
3060 let cargo_result = run_cargo_build(spec, &cargo_target_dir, progress);
3061 let cargo_build = build_started.elapsed();
3062 progress.record_phase(WasmBuildFailurePhase::CargoBuild, cargo_build);
3063 cargo_result?;
3064 let built_artifacts = expected_artifacts(spec, &cargo_target_dir);
3065 let validation_started = Instant::now();
3066 progress.begin_phase(WasmBuildFailurePhase::ArtifactPublication);
3067 let missing = missing_artifacts(&built_artifacts);
3068 progress.record_phase(
3069 WasmBuildFailurePhase::ArtifactPublication,
3070 validation_started.elapsed(),
3071 );
3072 if !missing.is_empty() {
3073 return Err(WasmBuildError::MissingArtifacts { paths: missing });
3074 }
3075
3076 let verified = verify_resolved_inputs(spec, &resolved, progress)?;
3077 input_resolution.include(verified.timings);
3078
3079 let publication_guard = shared_incremental.lock_prepared_publication()?;
3084
3085 let cached_artifacts = expected_artifacts(spec, &cache_entry);
3086 progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3087 if cargo_target_dir != cache_entry {
3088 copy_wasm_artifacts(&built_artifacts, &cached_artifacts)?;
3089 }
3090 publish_artifact_stamps(&cached_artifacts, fingerprint)?;
3091 materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
3092 record_cache_entry_use(&cache_entry)
3093 })?;
3094 drop(publication_guard);
3095
3096 Ok(WasmBuildOutcome::Built(complete_build_record(
3097 spec,
3098 BuildRecordInput {
3099 fingerprint,
3100 input_digest: resolved.input_digest,
3101 lock_wait,
3102 shared_incremental,
3103 input_resolution,
3104 cargo_build: Some(cargo_build),
3105 active_entry: &cache_entry,
3106 },
3107 total_started,
3108 progress,
3109 )?))
3110 })();
3111 finish_fingerprint_build(build_result, incomplete_directory, progress)
3112}
3113
3114pub fn prune_wasm_build_cache(
3123 target_dir: &Path,
3124 policy: ArtifactCachePrunePolicy,
3125) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3126 let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
3127 ensure_cache_directory_tag(target_dir)?;
3128
3129 prune_wasm_build_cache_locked(target_dir, policy, None)
3130}
3131
3132struct BuildRecordInput<'a> {
3133 fingerprint: InputDigest,
3134 input_digest: InputDigest,
3135 lock_wait: Duration,
3136 shared_incremental: WasmAcquisitionContext,
3137 input_resolution: WasmInputResolutionTimings,
3138 cargo_build: Option<Duration>,
3139 active_entry: &'a Path,
3140}
3141
3142fn complete_build_record(
3143 spec: &WasmBuildSpec,
3144 input: BuildRecordInput<'_>,
3145 total_started: Instant,
3146 progress: &mut ProgressReporter<'_>,
3147) -> Result<WasmBuildRecord, WasmBuildError> {
3148 let retention = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3149 RetainedCacheEntry::acquire(input.active_entry).map_err(wasm_cache_fs_error)
3150 })?;
3151 let (maintenance, cache_maintenance) = spec.prune_policy.map_or((None, None), |policy| {
3152 progress.run_phase(WasmBuildProgressPhase::ExactCacheMaintenance, || {
3153 let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
3154 let identity = policy.maintenance_identity();
3155 perform_scheduled_cache_maintenance(&cache_root, spec.prune_interval, &identity, || {
3156 prune_wasm_build_cache_locked(&spec.target_dir, policy, Some(input.active_entry))
3157 .map_err(|error| error.to_string())
3158 })
3159 })
3160 });
3161 Ok(WasmBuildRecord {
3162 fingerprint: input.fingerprint,
3163 input_digest: input.input_digest,
3164 artifacts: expected_artifacts(spec, input.active_entry),
3165 retention,
3166 timings: WasmBuildTimings {
3167 lock_wait: input.lock_wait,
3168 shared_incremental_lock_wait: input.shared_incremental.lock_wait,
3169 input_resolution: input.input_resolution,
3170 cargo_build: input.cargo_build,
3171 cache_maintenance,
3172 total: total_started.elapsed(),
3173 },
3174 maintenance,
3175 shared_incremental_maintenance: input.shared_incremental.maintenance,
3176 })
3177}
3178
3179fn prune_wasm_build_cache_locked(
3180 target_dir: &Path,
3181 policy: ArtifactCachePrunePolicy,
3182 protected_entry: Option<&Path>,
3183) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3184 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
3185 prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
3186 .map_err(wasm_cache_fs_error)
3187}
3188
3189struct IncompleteBuildDirectory {
3190 path: PathBuf,
3191 armed: bool,
3192}
3193
3194impl IncompleteBuildDirectory {
3195 const fn new(path: PathBuf) -> Self {
3196 Self { path, armed: true }
3197 }
3198
3199 fn preserve(mut self) {
3200 self.armed = false;
3201 }
3202
3203 fn cleanup(mut self) -> io::Result<()> {
3204 let result = remove_path_if_present(&self.path);
3205 if result.is_ok() {
3206 self.armed = false;
3207 }
3208 result
3209 }
3210}
3211
3212impl Drop for IncompleteBuildDirectory {
3213 fn drop(&mut self) {
3214 if self.armed {
3215 let _ = remove_path_if_present(&self.path);
3216 }
3217 }
3218}
3219
3220fn finish_fingerprint_build(
3221 result: Result<WasmBuildOutcome, WasmBuildError>,
3222 incomplete_directory: IncompleteBuildDirectory,
3223 progress: &mut ProgressReporter<'_>,
3224) -> Result<WasmBuildOutcome, WasmBuildError> {
3225 match result {
3226 Ok(outcome) => {
3227 incomplete_directory.preserve();
3228 Ok(outcome)
3229 }
3230 Err(build_error) => Err(cleanup_failed_fingerprint_build(
3231 build_error,
3232 incomplete_directory,
3233 progress,
3234 )),
3235 }
3236}
3237
3238fn cleanup_failed_fingerprint_build(
3239 build_error: WasmBuildError,
3240 incomplete_directory: IncompleteBuildDirectory,
3241 progress: &mut ProgressReporter<'_>,
3242) -> WasmBuildError {
3243 let path = incomplete_directory.path.clone();
3244 let primary_phase = progress.failure_phase;
3245 let cleanup_started = Instant::now();
3246 let cleanup = incomplete_directory.cleanup();
3247 progress.record_phase(WasmBuildFailurePhase::Cleanup, cleanup_started.elapsed());
3248 match cleanup {
3249 Ok(()) => {
3250 progress.failure_phase = primary_phase;
3251 build_error
3252 }
3253 Err(source) => WasmBuildError::FailedBuildCleanup {
3254 build_error: Box::new(build_error),
3255 path,
3256 source,
3257 },
3258 }
3259}
3260
3261fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
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(&lock_path).map_err(wasm_cache_fs_error)
3265}
3266
3267fn lock_wasm_build_cache_with_progress(
3268 target_dir: &Path,
3269 progress: &mut ProgressReporter<'_>,
3270) -> Result<(File, Duration), WasmBuildError> {
3271 progress.begin_phase(WasmBuildFailurePhase::ExactCacheCoordination);
3272 create_dir_all(target_dir, "create Cargo target directory")?;
3273 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3274 lock_cache_file_with_progress(&lock_path, WasmBuildProgressPhase::ExactCacheLock, progress)
3275}
3276
3277fn lock_shared_incremental_target(
3278 spec: &WasmBuildSpec,
3279) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3280 lock_shared_incremental_target_internal(spec, None)
3281}
3282
3283fn lock_shared_incremental_target_with_progress(
3284 spec: &WasmBuildSpec,
3285 progress: &mut ProgressReporter<'_>,
3286) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3287 let target_dir = shared_incremental_target(spec)
3288 .expect("validated shared acquisition must have a shared Cargo target");
3289 progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted { target_dir });
3290 let (lock, wait, canonical) = lock_shared_incremental_target_internal(spec, Some(progress))?;
3291 progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
3292 target_dir: canonical.clone(),
3293 wait,
3294 });
3295 Ok((lock, wait, canonical))
3296}
3297
3298fn lock_shared_incremental_target_internal(
3299 spec: &WasmBuildSpec,
3300 mut progress: Option<&mut ProgressReporter<'_>>,
3301) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3302 if let Some(progress) = progress.as_deref_mut() {
3303 progress.begin_phase(WasmBuildFailurePhase::SharedTargetCoordination);
3304 }
3305 let target_dir =
3306 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
3307 message: "shared incremental target is not configured".to_owned(),
3308 })?;
3309 create_dir_all(
3310 &target_dir,
3311 "create shared incremental Cargo target directory",
3312 )?;
3313 ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
3314 let canonical = target_dir
3315 .canonicalize()
3316 .map_err(|source| WasmBuildError::Io {
3317 operation: "resolve shared incremental Cargo target directory",
3318 path: target_dir.clone(),
3319 source,
3320 })?;
3321 let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
3322 let (lock, wait) = if let Some(progress) = progress {
3323 lock_cache_file_with_progress(
3324 &lock_path,
3325 WasmBuildProgressPhase::SharedTargetLock,
3326 progress,
3327 )?
3328 } else {
3329 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)?
3330 };
3331 Ok((lock, wait, canonical))
3332}
3333
3334fn lock_cache_file_with_progress(
3335 lock_path: &Path,
3336 phase: WasmBuildProgressPhase,
3337 progress: &mut ProgressReporter<'_>,
3338) -> Result<(File, Duration), WasmBuildError> {
3339 let failure_phase = progress_failure_phase(phase);
3340 let started = Instant::now();
3341 progress.begin_phase(failure_phase);
3342 let result = if !progress.is_observed() || progress.config.heartbeat_interval.is_none() {
3343 lock_cache_file(lock_path).map_err(wasm_cache_fs_error)
3344 } else {
3345 let heartbeat_interval = progress
3346 .config
3347 .heartbeat_interval
3348 .expect("observed cache lock must have a heartbeat interval");
3349 lock_cache_file_with_wait_observer(lock_path, heartbeat_interval, |elapsed| {
3350 progress.emit_heartbeat_if_due(phase, elapsed);
3351 })
3352 .map_err(wasm_cache_fs_error)
3353 };
3354 progress.record_phase(failure_phase, started.elapsed());
3355 result
3356}
3357
3358fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
3359 ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
3360}
3361
3362fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
3363 record_entry_use(path).map_err(wasm_cache_fs_error)
3364}
3365
3366fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
3367 WasmBuildError::Io {
3368 operation: error.operation,
3369 path: error.path,
3370 source: error.source,
3371 }
3372}
3373
3374fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3375 if spec.packages.is_empty() {
3376 return Err(WasmBuildError::InvalidSpec {
3377 message: "at least one Cargo package is required".to_owned(),
3378 });
3379 }
3380 if spec.profile_target_dir.is_empty() {
3381 return Err(WasmBuildError::InvalidSpec {
3382 message: "Cargo profile target directory must not be empty".to_owned(),
3383 });
3384 }
3385 if spec.target.is_empty() {
3386 return Err(WasmBuildError::InvalidSpec {
3387 message: "Cargo compilation target must not be empty".to_owned(),
3388 });
3389 }
3390 if spec.extra_env.contains_key(OsStr::new("CARGO_TARGET_DIR"))
3391 || spec.cargo_profile_args.iter().any(|argument| {
3392 argument == OsStr::new("--target-dir")
3393 || argument.as_encoded_bytes().starts_with(b"--target-dir=")
3394 })
3395 {
3396 return Err(WasmBuildError::InvalidSpec {
3397 message: "Cargo target directories are owned by the build specification; use target_dir or with_shared_incremental_target instead of command overrides".to_owned(),
3398 });
3399 }
3400 if matches!(
3401 &spec.cache_mode,
3402 WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
3403 ) {
3404 return Err(WasmBuildError::InvalidSpec {
3405 message: "shared incremental Cargo target directory must not be empty".to_owned(),
3406 });
3407 }
3408 if spec.shared_incremental_maintenance_config.is_some()
3409 && !matches!(
3410 spec.cache_mode,
3411 WasmBuildCacheMode::SharedIncremental { .. }
3412 )
3413 {
3414 return Err(WasmBuildError::InvalidSpec {
3415 message:
3416 "scheduled shared-target maintenance requires a shared incremental Cargo target"
3417 .to_owned(),
3418 });
3419 }
3420 Ok(())
3421}
3422
3423fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3424 build_fingerprint_with_progress(spec, &mut ProgressReporter::silent())
3425}
3426
3427fn build_fingerprint_with_progress(
3428 spec: &WasmBuildSpec,
3429 progress: &mut ProgressReporter<'_>,
3430) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3431 let total_started = Instant::now();
3432 let (cargo_identity, rustc_identity, tool_identity) = resolve_tool_identity(spec, progress)?;
3433
3434 let metadata_started = Instant::now();
3435 let metadata = progress.run_phase(WasmBuildProgressPhase::CargoMetadata, || {
3436 cargo_metadata(spec)
3437 })?;
3438 let cargo_metadata = metadata_started.elapsed();
3439
3440 let discovery_started = Instant::now();
3441 let (inputs, exclusions) =
3442 progress.run_phase(WasmBuildProgressPhase::InputDiscovery, || {
3443 let parsed = ParsedCargoMetadata::parse(&metadata)
3444 .map_err(|message| invalid_metadata(&message))?;
3445 let inputs = resolve_local_inputs(spec, &parsed)?;
3446 validate_shared_incremental_target_boundary(spec, &inputs.validation_inputs)?;
3447 let exclusions = source_exclusions(spec, &inputs.validation_inputs);
3448 Ok::<_, WasmBuildError>((inputs, exclusions))
3449 })?;
3450 let input_discovery = discovery_started.elapsed();
3451
3452 let hashing_started = Instant::now();
3453 let (input_digest, validation_digest) =
3454 progress.run_phase(WasmBuildProgressPhase::ContentHashing, || {
3455 let mut cache = LabeledPathDigestCache::default();
3456 digest_resolved_local_inputs(
3457 &inputs,
3458 &exclusions,
3459 &mut cache,
3460 &spec.workspace_root,
3461 "hash Wasm build inputs",
3462 "hash semantic Wasm build inputs",
3463 )
3464 })?;
3465 let content_hashing = hashing_started.elapsed();
3466
3467 let fingerprint =
3468 finish_build_fingerprint(spec, &cargo_identity, &rustc_identity, input_digest);
3469 Ok(ResolvedCargoBuildInputs {
3470 fingerprint,
3471 input_digest,
3472 validation_digest,
3473 inputs: inputs
3474 .validation_inputs
3475 .into_iter()
3476 .map(|(label, path)| CargoBuildInput { label, path })
3477 .collect(),
3478 exclusions,
3479 timings: WasmInputResolutionTimings {
3480 tool_identity,
3481 cargo_metadata,
3482 input_discovery,
3483 content_hashing,
3484 total: total_started.elapsed(),
3485 },
3486 })
3487}
3488
3489fn finish_build_fingerprint(
3490 spec: &WasmBuildSpec,
3491 cargo_identity: &[u8],
3492 rustc_identity: &[u8],
3493 input_digest: InputDigest,
3494) -> InputDigest {
3495 let mut hasher = InputHasher::new(CACHE_FORMAT_VERSION);
3496 let mut packages = spec.packages.clone();
3497 packages.sort();
3498 packages.dedup();
3499 for package in packages {
3500 hasher.field("package", package.as_bytes());
3501 }
3502 hasher.field("target", spec.target.as_bytes());
3503 hasher.field("profile-target-dir", spec.profile_target_dir.as_bytes());
3504 for argument in &spec.cargo_profile_args {
3505 hasher.field("cargo-argument", &os_bytes(argument));
3506 }
3507 for (key, value) in effective_environment(spec) {
3508 hasher.field("environment-key", &os_bytes(&key));
3509 if let Some(value) = value {
3510 hasher.field("environment-value", &os_bytes(&value));
3511 } else {
3512 hasher.field("environment-unset", b"");
3513 }
3514 }
3515 hasher.field("cargo-identity", cargo_identity);
3516 hasher.field("rustc-identity", rustc_identity);
3517 hasher.field("source-input-digest", input_digest.as_bytes());
3518 hasher.finish()
3519}
3520
3521fn command_identity(
3522 spec: &WasmBuildSpec,
3523 phase: WasmBuildPhase,
3524 program: &OsStr,
3525 arguments: &[&str],
3526) -> Result<Vec<u8>, WasmBuildError> {
3527 let mut command = Command::new(program);
3528 command.current_dir(&spec.workspace_root).args(arguments);
3529 apply_command_environment(&mut command, spec);
3530 let output = command
3531 .output()
3532 .map_err(|source| WasmBuildError::CommandSpawn {
3533 phase,
3534 program: program.to_owned(),
3535 source,
3536 })?;
3537 ensure_command_success(phase, output).map(|output| {
3538 let mut identity = output.stdout;
3539 identity.extend_from_slice(&output.stderr);
3540 identity
3541 })
3542}
3543
3544fn cargo_metadata(spec: &WasmBuildSpec) -> Result<Value, WasmBuildError> {
3545 let mut command = Command::new(&spec.cargo_program);
3546 command
3547 .current_dir(&spec.workspace_root)
3548 .args(["metadata", "--format-version", "1"]);
3549 for argument in metadata_arguments(&spec.cargo_profile_args) {
3550 command.arg(argument);
3551 }
3552 apply_command_environment(&mut command, spec);
3553 let output = command
3554 .output()
3555 .map_err(|source| WasmBuildError::CommandSpawn {
3556 phase: WasmBuildPhase::CargoMetadata,
3557 program: spec.cargo_program.clone(),
3558 source,
3559 })?;
3560 let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
3561 serde_json::from_slice(&output.stdout).map_err(|error| WasmBuildError::InvalidMetadata {
3562 message: format!("Cargo metadata was not valid JSON: {error}"),
3563 })
3564}
3565
3566fn metadata_arguments(arguments: &[OsString]) -> Vec<OsString> {
3567 let mut selected = Vec::new();
3568 let mut arguments = arguments.iter();
3569 while let Some(argument) = arguments.next() {
3570 let argument_text = argument.to_string_lossy();
3571 match argument_text.as_ref() {
3572 "--all-features" | "--no-default-features" | "--locked" | "--offline" | "--frozen" => {
3573 selected.push(argument.clone());
3574 }
3575 "--features" | "-F" | "--filter-platform" => {
3576 selected.push(argument.clone());
3577 if let Some(value) = arguments.next() {
3578 selected.push(value.clone());
3579 }
3580 }
3581 _ if argument_text.starts_with("--features=")
3582 || argument_text.starts_with("-F")
3583 || argument_text.starts_with("--filter-platform=") =>
3584 {
3585 selected.push(argument.clone());
3586 }
3587 _ => {}
3588 }
3589 }
3590 selected
3591}
3592
3593#[derive(Clone)]
3594struct MetadataPackage {
3595 id: String,
3596 name: String,
3597 version: String,
3598 manifest_path: PathBuf,
3599 is_local: bool,
3600 source: Option<String>,
3601 semantic_fields: Vec<(&'static str, Option<String>)>,
3602}
3603
3604struct ParsedCargoMetadata<'a> {
3607 packages: HashMap<String, MetadataPackage>,
3608 workspace_members: HashSet<&'a str>,
3609 nodes: HashMap<String, MetadataNode<'a>>,
3610 workspace_root: Option<&'a str>,
3611}
3612
3613struct MetadataNode<'a> {
3614 dependencies: Vec<String>,
3615 value: &'a Value,
3616}
3617
3618impl<'a> ParsedCargoMetadata<'a> {
3619 fn parse(metadata: &'a Value) -> Result<Self, String> {
3620 let packages = metadata_packages(metadata)?;
3621 let workspace_members = metadata
3622 .get("workspace_members")
3623 .and_then(Value::as_array)
3624 .ok_or_else(|| "Cargo metadata has no workspace member array".to_owned())?
3625 .iter()
3626 .filter_map(Value::as_str)
3627 .collect();
3628 let values = metadata
3629 .pointer("/resolve/nodes")
3630 .and_then(Value::as_array)
3631 .ok_or_else(|| "Cargo metadata has no resolved dependency nodes".to_owned())?;
3632 let mut nodes = HashMap::new();
3633 for value in values {
3634 let id = required_string(value, "id")?;
3635 let dependencies = value
3636 .get("deps")
3637 .and_then(Value::as_array)
3638 .ok_or_else(|| "Cargo metadata dependency node has no deps array".to_owned())?
3639 .iter()
3640 .map(|dependency| required_string(dependency, "pkg"))
3641 .collect::<Result<_, _>>()?;
3642 nodes.insert(
3643 id,
3644 MetadataNode {
3645 dependencies,
3646 value,
3647 },
3648 );
3649 }
3650 Ok(Self {
3651 packages,
3652 workspace_members,
3653 nodes,
3654 workspace_root: metadata.get("workspace_root").and_then(Value::as_str),
3655 })
3656 }
3657}
3658
3659const SEMANTIC_PACKAGE_FIELDS: &[&str] = &[
3660 "authors",
3661 "default_run",
3662 "description",
3663 "documentation",
3664 "edition",
3665 "homepage",
3666 "license",
3667 "license_file",
3668 "links",
3669 "metadata",
3670 "name",
3671 "readme",
3672 "repository",
3673 "rust_version",
3674 "version",
3675];
3676
3677struct LockedPackageIdentity {
3678 name: String,
3679 version: String,
3680 source: String,
3681 checksum: Option<String>,
3682}
3683
3684fn resolve_local_inputs(
3685 spec: &WasmBuildSpec,
3686 metadata: &ParsedCargoMetadata<'_>,
3687) -> Result<ResolvedLocalInputs, WasmBuildError> {
3688 let mut selected_ids = selected_package_ids(spec, metadata)?;
3689 let mut closure = BTreeSet::new();
3690 while let Some(id) = selected_ids.pop_front() {
3691 if !closure.insert(id.clone()) {
3692 continue;
3693 }
3694 if let Some(node) = metadata.nodes.get(&id) {
3695 selected_ids.extend(node.dependencies.iter().cloned());
3696 }
3697 }
3698
3699 let workspace_root = metadata
3700 .workspace_root
3701 .map_or_else(|| spec.workspace_root.clone(), PathBuf::from);
3702 let projection = semantic_workspace_projection(metadata, &closure, &workspace_root)?;
3703 let mut validation_inputs = workspace_configuration_inputs(spec, &workspace_root)?;
3704 append_package_inputs(
3705 &mut validation_inputs,
3706 &metadata.packages,
3707 closure,
3708 &workspace_root,
3709 )?;
3710 append_additional_inputs(&mut validation_inputs, spec, &workspace_root);
3711 let fingerprint = projection.map_or(LocalInputFingerprint::Conservative, |workspace| {
3712 LocalInputFingerprint::Projected {
3713 inputs: validation_inputs
3714 .iter()
3715 .filter(|(label, _)| !is_broad_workspace_input(label))
3716 .cloned()
3717 .collect(),
3718 workspace,
3719 }
3720 });
3721 Ok(ResolvedLocalInputs {
3722 validation_inputs,
3723 fingerprint,
3724 })
3725}
3726
3727fn metadata_packages(metadata: &Value) -> Result<HashMap<String, MetadataPackage>, String> {
3728 let packages_value = metadata
3729 .get("packages")
3730 .and_then(Value::as_array)
3731 .ok_or_else(|| "Cargo metadata has no package array".to_owned())?;
3732 let mut packages = HashMap::new();
3733 for value in packages_value {
3734 let source = optional_string(value, "source")?;
3735 let package = MetadataPackage {
3736 id: required_string(value, "id")?,
3737 name: required_string(value, "name")?,
3738 version: required_string(value, "version")?,
3739 manifest_path: PathBuf::from(required_string(value, "manifest_path")?),
3740 is_local: value.get("source").is_some_and(Value::is_null),
3741 source,
3742 semantic_fields: SEMANTIC_PACKAGE_FIELDS
3743 .iter()
3744 .map(|field| (*field, value.get(*field).map(Value::to_string)))
3745 .collect(),
3746 };
3747 packages.insert(package.id.clone(), package);
3748 }
3749 Ok(packages)
3750}
3751
3752fn selected_package_ids(
3753 spec: &WasmBuildSpec,
3754 metadata: &ParsedCargoMetadata<'_>,
3755) -> Result<VecDeque<String>, WasmBuildError> {
3756 let mut selected_ids = VecDeque::new();
3757 for requested in &spec.packages {
3758 let matches = metadata
3759 .packages
3760 .values()
3761 .filter(|package| {
3762 package.name == *requested
3763 && metadata.workspace_members.contains(package.id.as_str())
3764 })
3765 .map(|package| package.id.clone())
3766 .collect::<Vec<_>>();
3767 match matches.as_slice() {
3768 [id] => selected_ids.push_back(id.clone()),
3769 [] => {
3770 return Err(WasmBuildError::InvalidSpec {
3771 message: format!("Cargo workspace contains no package named `{requested}`"),
3772 });
3773 }
3774 _ => {
3775 return Err(WasmBuildError::InvalidSpec {
3776 message: format!("Cargo workspace package name `{requested}` is ambiguous"),
3777 });
3778 }
3779 }
3780 }
3781 Ok(selected_ids)
3782}
3783
3784fn semantic_workspace_projection(
3785 metadata: &ParsedCargoMetadata<'_>,
3786 closure: &BTreeSet<String>,
3787 workspace_root: &Path,
3788) -> Result<Option<InputDigest>, WasmBuildError> {
3789 let locked_packages = locked_package_identities(workspace_root)?;
3792 let mut identities = HashMap::new();
3793 for id in closure {
3794 let package = metadata
3795 .packages
3796 .get(id)
3797 .ok_or_else(|| invalid_metadata(&format!("resolved package `{id}` is missing")))?;
3798 let Some(identity) = semantic_package_identity(package, workspace_root, &locked_packages)
3799 else {
3800 return Ok(None);
3801 };
3802 identities.insert(id.as_str(), identity);
3803 }
3804
3805 let mut projected_packages = closure
3806 .iter()
3807 .map(|id| {
3808 let package = metadata
3809 .packages
3810 .get(id)
3811 .expect("selected package closure was validated above");
3812 let identity = identities[id.as_str()];
3813 let node = metadata.nodes.get(id).ok_or_else(|| {
3814 invalid_metadata(&format!("resolved package `{id}` has no dependency node"))
3815 })?;
3816 let projection = semantic_package_projection(package, node.value, &identities)?;
3817 Ok::<_, WasmBuildError>((identity, projection))
3818 })
3819 .collect::<Result<Vec<_>, _>>()?;
3820 projected_packages.sort_by_key(|(identity, _)| *identity);
3821
3822 let root_manifest = workspace_root.join("Cargo.toml");
3823 let root_contents =
3824 fs::read_to_string(&root_manifest).map_err(|source| WasmBuildError::Io {
3825 operation: "read workspace manifest for semantic projection",
3826 path: root_manifest.clone(),
3827 source,
3828 })?;
3829 let root = toml::from_str::<TomlValue>(&root_contents).map_err(|error| {
3830 invalid_metadata(&format!(
3831 "workspace manifest could not be projected as TOML: {error}"
3832 ))
3833 })?;
3834
3835 let mut hasher = InputHasher::new("wasm-semantic-workspace-projection-v1");
3836 for (identity, projection) in projected_packages {
3837 hasher.field("package-identity", identity.as_bytes());
3838 hasher.field("package-projection", projection.as_bytes());
3839 }
3840 hash_toml_setting(&mut hasher, "cargo-features", root.get("cargo-features"));
3841 hash_toml_setting(&mut hasher, "profile", root.get("profile"));
3842 let workspace = root.get("workspace").and_then(TomlValue::as_table);
3843 hash_toml_setting(
3844 &mut hasher,
3845 "workspace-resolver",
3846 workspace.and_then(|table| table.get("resolver")),
3847 );
3848 hash_toml_setting(
3849 &mut hasher,
3850 "workspace-lints",
3851 workspace.and_then(|table| table.get("lints")),
3852 );
3853 Ok(Some(hasher.finish()))
3854}
3855
3856fn locked_package_identities(
3857 workspace_root: &Path,
3858) -> Result<Vec<LockedPackageIdentity>, WasmBuildError> {
3859 let lockfile = workspace_root.join("Cargo.lock");
3860 let contents = match fs::read_to_string(&lockfile) {
3861 Ok(contents) => contents,
3862 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
3863 Err(source) => {
3864 return Err(WasmBuildError::Io {
3865 operation: "read Cargo lockfile for semantic projection",
3866 path: lockfile,
3867 source,
3868 });
3869 }
3870 };
3871 let lock = toml::from_str::<TomlValue>(&contents).map_err(|error| {
3872 invalid_metadata(&format!(
3873 "Cargo lockfile could not be projected as TOML: {error}"
3874 ))
3875 })?;
3876 let Some(packages) = lock.get("package").and_then(TomlValue::as_array) else {
3877 return Ok(Vec::new());
3878 };
3879 packages
3880 .iter()
3881 .filter_map(|package| {
3882 let Some(table) = package.as_table() else {
3883 return Some(Err(invalid_metadata(
3884 "Cargo lockfile package entry is not a table",
3885 )));
3886 };
3887 let source = table.get("source")?.as_str().map(str::to_owned);
3888 Some(
3889 source
3890 .ok_or_else(|| {
3891 invalid_metadata("Cargo lockfile package source is not a string")
3892 })
3893 .and_then(|source| {
3894 Ok(LockedPackageIdentity {
3895 name: required_toml_string(table, "name", "Cargo lockfile package")?,
3896 version: required_toml_string(
3897 table,
3898 "version",
3899 "Cargo lockfile package",
3900 )?,
3901 source,
3902 checksum: optional_toml_string(
3903 table,
3904 "checksum",
3905 "Cargo lockfile package",
3906 )?,
3907 })
3908 }),
3909 )
3910 })
3911 .collect()
3912}
3913
3914fn required_toml_string(
3915 table: &toml::Table,
3916 field: &str,
3917 context: &str,
3918) -> Result<String, WasmBuildError> {
3919 table
3920 .get(field)
3921 .and_then(TomlValue::as_str)
3922 .map(str::to_owned)
3923 .ok_or_else(|| invalid_metadata(&format!("{context} `{field}` is missing or not a string")))
3924}
3925
3926fn optional_toml_string(
3927 table: &toml::Table,
3928 field: &str,
3929 context: &str,
3930) -> Result<Option<String>, WasmBuildError> {
3931 match table.get(field) {
3932 None => Ok(None),
3933 Some(TomlValue::String(value)) => Ok(Some(value.clone())),
3934 Some(_) => Err(invalid_metadata(&format!(
3935 "{context} `{field}` is not a string"
3936 ))),
3937 }
3938}
3939
3940fn semantic_package_identity(
3941 package: &MetadataPackage,
3942 workspace_root: &Path,
3943 locked_packages: &[LockedPackageIdentity],
3944) -> Option<InputDigest> {
3945 let mut hasher = InputHasher::new("wasm-semantic-package-identity-v1");
3946 hasher.field("name", package.name.as_bytes());
3947 hasher.field("version", package.version.as_bytes());
3948 if package.is_local {
3949 let manifest = package.manifest_path.strip_prefix(workspace_root).ok()?;
3950 let package_root = package.manifest_path.parent()?;
3951 if package_root == workspace_root {
3952 return None;
3953 }
3954 hasher.field("local-manifest", &os_bytes(manifest.as_os_str()));
3955 } else {
3956 let metadata_source = package.source.as_deref()?;
3957 let locked = locked_packages.iter().find(|locked| {
3958 locked.name == package.name
3959 && locked.version == package.version
3960 && locked.source == metadata_source
3961 })?;
3962 match locked.source.as_str() {
3963 source if source.starts_with("registry+") && locked.checksum.is_some() => {}
3964 source if source.starts_with("git+") && source.contains('#') => {}
3965 _ => return None,
3966 }
3967 hasher.field("external-package-id", package.id.as_bytes());
3968 hasher.field("external-source", locked.source.as_bytes());
3969 hasher.field(
3970 "external-checksum",
3971 locked.checksum.as_deref().unwrap_or_default().as_bytes(),
3972 );
3973 }
3974 Some(hasher.finish())
3975}
3976
3977fn semantic_package_projection(
3978 package: &MetadataPackage,
3979 node: &Value,
3980 identities: &HashMap<&str, InputDigest>,
3981) -> Result<InputDigest, WasmBuildError> {
3982 let mut hasher = InputHasher::new("wasm-semantic-package-projection-v1");
3986 for (field, value) in &package.semantic_fields {
3987 hasher.field("package-field-name", field.as_bytes());
3988 match value {
3989 Some(value) => hasher.field("package-field-value", value.as_bytes()),
3990 None => hasher.field("package-field-missing", b""),
3991 }
3992 }
3993
3994 let mut features = node
3995 .get("features")
3996 .and_then(Value::as_array)
3997 .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no features array"))?
3998 .iter()
3999 .map(|feature| {
4000 feature.as_str().map(str::to_owned).ok_or_else(|| {
4001 invalid_metadata("Cargo metadata dependency feature is not a string")
4002 })
4003 })
4004 .collect::<Result<Vec<_>, _>>()?;
4005 features.sort();
4006 for feature in features {
4007 hasher.field("enabled-feature", feature.as_bytes());
4008 }
4009
4010 let mut dependencies = node
4011 .get("deps")
4012 .and_then(Value::as_array)
4013 .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
4014 .iter()
4015 .map(|dependency| {
4016 let name = required_string(dependency, "name")
4017 .map_err(|message| invalid_metadata(&message))?;
4018 let package_id =
4019 required_string(dependency, "pkg").map_err(|message| invalid_metadata(&message))?;
4020 let identity = identities
4021 .get(package_id.as_str())
4022 .copied()
4023 .ok_or_else(|| {
4024 invalid_metadata(&format!(
4025 "dependency `{package_id}` is outside the selected package closure"
4026 ))
4027 })?;
4028 let kinds = dependency
4029 .get("dep_kinds")
4030 .ok_or_else(|| invalid_metadata("Cargo metadata dependency has no kind array"))?
4031 .to_string();
4032 Ok::<_, WasmBuildError>((name, identity, kinds))
4033 })
4034 .collect::<Result<Vec<_>, _>>()?;
4035 dependencies.sort();
4036 for (name, identity, kinds) in dependencies {
4037 hasher.field("dependency-name", name.as_bytes());
4038 hasher.field("dependency-identity", identity.as_bytes());
4039 hasher.field("dependency-kinds", kinds.as_bytes());
4040 }
4041 Ok(hasher.finish())
4042}
4043
4044fn hash_toml_setting(hasher: &mut InputHasher, label: &str, value: Option<&TomlValue>) {
4045 hasher.field("workspace-setting-name", label.as_bytes());
4046 match value {
4047 Some(value) => hasher.field("workspace-setting-value", value.to_string().as_bytes()),
4048 None => hasher.field("workspace-setting-missing", b""),
4049 }
4050}
4051
4052fn is_broad_workspace_input(label: &Path) -> bool {
4053 label == Path::new("workspace/Cargo.toml") || label == Path::new("workspace/Cargo.lock")
4054}
4055
4056fn digest_resolved_local_inputs(
4057 inputs: &ResolvedLocalInputs,
4058 exclusions: &[PathBuf],
4059 cache: &mut LabeledPathDigestCache,
4060 error_path: &Path,
4061 validation_operation: &'static str,
4062 semantic_operation: &'static str,
4063) -> Result<(InputDigest, InputDigest), WasmBuildError> {
4064 let validation_digest = digest_labeled_paths_composable(
4065 "wasm-source-inputs-v1",
4066 &inputs.validation_inputs,
4067 exclusions,
4068 cache,
4069 )
4070 .map_err(|source| WasmBuildError::Io {
4071 operation: validation_operation,
4072 path: error_path.to_owned(),
4073 source,
4074 })?;
4075 let input_digest = semantic_input_digest(inputs, validation_digest, exclusions, cache)
4076 .map_err(|source| WasmBuildError::Io {
4077 operation: semantic_operation,
4078 path: error_path.to_owned(),
4079 source,
4080 })?;
4081 Ok((input_digest, validation_digest))
4082}
4083
4084fn semantic_input_digest(
4085 inputs: &ResolvedLocalInputs,
4086 validation_digest: InputDigest,
4087 exclusions: &[PathBuf],
4088 cache: &mut LabeledPathDigestCache,
4089) -> io::Result<InputDigest> {
4090 let LocalInputFingerprint::Projected {
4091 inputs: fingerprint_inputs,
4092 workspace,
4093 } = &inputs.fingerprint
4094 else {
4095 return Ok(validation_digest);
4096 };
4097 let path_digest = digest_labeled_paths_composable(
4098 "wasm-source-inputs-v1",
4099 fingerprint_inputs,
4100 exclusions,
4101 cache,
4102 )?;
4103 let mut hasher = InputHasher::new("wasm-semantic-source-inputs-v1");
4104 hasher.field("path-input-digest", path_digest.as_bytes());
4105 hasher.field("workspace-projection", workspace.as_bytes());
4106 Ok(hasher.finish())
4107}
4108
4109fn workspace_configuration_inputs(
4110 spec: &WasmBuildSpec,
4111 workspace_root: &Path,
4112) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
4113 let mut inputs = Vec::new();
4114 add_if_present(
4115 &mut inputs,
4116 "workspace/Cargo.toml",
4117 workspace_root.join("Cargo.toml"),
4118 );
4119 add_if_present(
4120 &mut inputs,
4121 "workspace/Cargo.lock",
4122 workspace_root.join("Cargo.lock"),
4123 );
4124 add_if_present(
4125 &mut inputs,
4126 "workspace/rust-toolchain.toml",
4127 workspace_root.join("rust-toolchain.toml"),
4128 );
4129 add_if_present(
4130 &mut inputs,
4131 "workspace/rust-toolchain",
4132 workspace_root.join("rust-toolchain"),
4133 );
4134 append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
4135 Ok(inputs)
4136}
4137
4138fn append_cargo_configuration_inputs(
4139 inputs: &mut Vec<(PathBuf, PathBuf)>,
4140 spec: &WasmBuildSpec,
4141 workspace_root: &Path,
4142) -> Result<(), WasmBuildError> {
4143 let invocation_root =
4144 spec.workspace_root
4145 .canonicalize()
4146 .map_err(|source| WasmBuildError::Io {
4147 operation: "resolve Cargo invocation directory",
4148 path: spec.workspace_root.clone(),
4149 source,
4150 })?;
4151 let canonical_workspace =
4152 workspace_root
4153 .canonicalize()
4154 .map_err(|source| WasmBuildError::Io {
4155 operation: "resolve Cargo workspace directory",
4156 path: workspace_root.to_owned(),
4157 source,
4158 })?;
4159
4160 let mut roots = invocation_root
4161 .ancestors()
4162 .filter_map(|directory| effective_cargo_config(&directory.join(".cargo")))
4163 .collect::<Vec<_>>();
4164 if let Some(cargo_home) = effective_cargo_home(spec, &invocation_root)
4165 && let Some(config) = effective_cargo_config(&cargo_home)
4166 {
4167 roots.push(config);
4168 }
4169
4170 let mut visited = BTreeSet::new();
4171 for config in roots {
4172 append_cargo_configuration_tree(
4173 inputs,
4174 &config,
4175 &canonical_workspace,
4176 &mut visited,
4177 false,
4178 )?;
4179 }
4180 Ok(())
4181}
4182
4183fn effective_cargo_config(directory: &Path) -> Option<PathBuf> {
4184 let extensionless = directory.join("config");
4185 if extensionless.exists() {
4186 return Some(extensionless);
4187 }
4188 let toml = directory.join("config.toml");
4189 toml.exists().then_some(toml)
4190}
4191
4192fn effective_cargo_home(spec: &WasmBuildSpec, invocation_root: &Path) -> Option<PathBuf> {
4193 if let Some(cargo_home) = command_environment_value(spec, "CARGO_HOME") {
4194 let cargo_home = PathBuf::from(cargo_home);
4195 return Some(if cargo_home.is_absolute() {
4196 cargo_home
4197 } else {
4198 invocation_root.join(cargo_home)
4199 });
4200 }
4201
4202 default_home_directory(spec).map(|home| {
4203 let home = if home.is_absolute() {
4204 home
4205 } else {
4206 invocation_root.join(home)
4207 };
4208 home.join(".cargo")
4209 })
4210}
4211
4212#[cfg(windows)]
4213fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4214 command_environment_value(spec, "USERPROFILE")
4215 .or_else(|| command_environment_value(spec, "HOME"))
4216 .map(PathBuf::from)
4217}
4218
4219#[cfg(not(windows))]
4220fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4221 command_environment_value(spec, "HOME").map(PathBuf::from)
4222}
4223
4224fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
4225 spec.extra_env
4226 .get(OsStr::new(name))
4227 .cloned()
4228 .or_else(|| std::env::var_os(name))
4229}
4230
4231fn append_cargo_configuration_tree(
4232 inputs: &mut Vec<(PathBuf, PathBuf)>,
4233 config: &Path,
4234 workspace_root: &Path,
4235 visited: &mut BTreeSet<PathBuf>,
4236 optional: bool,
4237) -> Result<(), WasmBuildError> {
4238 let canonical = match config.canonicalize() {
4239 Ok(canonical) => canonical,
4240 Err(error) if optional && error.kind() == io::ErrorKind::NotFound => return Ok(()),
4241 Err(source) => {
4242 return Err(WasmBuildError::Io {
4243 operation: "resolve Cargo configuration",
4244 path: config.to_owned(),
4245 source,
4246 });
4247 }
4248 };
4249 if !visited.insert(canonical.clone()) {
4250 return Ok(());
4251 }
4252
4253 let contents = fs::read_to_string(&canonical).map_err(|source| WasmBuildError::Io {
4254 operation: "read Cargo configuration",
4255 path: canonical.clone(),
4256 source,
4257 })?;
4258 let configuration = toml::from_str::<TomlValue>(&contents).map_err(|error| {
4259 WasmBuildError::InvalidCargoConfiguration {
4260 path: canonical.clone(),
4261 message: error.to_string(),
4262 }
4263 })?;
4264 inputs.push((
4265 cargo_configuration_label(&canonical, workspace_root),
4266 canonical.clone(),
4267 ));
4268
4269 let Some(include) = configuration.get("include") else {
4270 return Ok(());
4271 };
4272 let parent = canonical
4273 .parent()
4274 .ok_or_else(|| WasmBuildError::InvalidCargoConfiguration {
4275 path: canonical.clone(),
4276 message: "configuration path has no parent directory".to_owned(),
4277 })?;
4278 for (included, optional) in cargo_configuration_includes(include, &canonical)? {
4279 let included = if included.is_absolute() {
4280 included
4281 } else {
4282 parent.join(included)
4283 };
4284 append_cargo_configuration_tree(inputs, &included, workspace_root, visited, optional)?;
4285 }
4286 Ok(())
4287}
4288
4289fn cargo_configuration_includes(
4290 include: &TomlValue,
4291 config: &Path,
4292) -> Result<Vec<(PathBuf, bool)>, WasmBuildError> {
4293 let values = match include {
4294 TomlValue::Array(values) => values.as_slice(),
4295 value => std::slice::from_ref(value),
4296 };
4297 values
4298 .iter()
4299 .map(|value| match value {
4300 TomlValue::String(path) => Ok((PathBuf::from(path), false)),
4301 TomlValue::Table(table) => {
4302 let path = table
4303 .get("path")
4304 .and_then(TomlValue::as_str)
4305 .ok_or_else(|| {
4306 invalid_cargo_configuration(
4307 config,
4308 "Cargo configuration include table requires a string `path`",
4309 )
4310 })?;
4311 let optional = table
4312 .get("optional")
4313 .map(|value| {
4314 value.as_bool().ok_or_else(|| {
4315 invalid_cargo_configuration(
4316 config,
4317 "Cargo configuration include `optional` must be a boolean",
4318 )
4319 })
4320 })
4321 .transpose()?
4322 .unwrap_or(false);
4323 Ok((PathBuf::from(path), optional))
4324 }
4325 _ => Err(invalid_cargo_configuration(
4326 config,
4327 "Cargo configuration `include` must contain paths or include tables",
4328 )),
4329 })
4330 .collect()
4331}
4332
4333fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
4334 if let Ok(relative) = config.strip_prefix(workspace_root) {
4335 return PathBuf::from("cargo-config/workspace").join(relative);
4336 }
4337 let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
4338 PathBuf::from("cargo-config/external").join(location.to_hex())
4339}
4340
4341fn invalid_cargo_configuration(path: &Path, message: &str) -> WasmBuildError {
4342 WasmBuildError::InvalidCargoConfiguration {
4343 path: path.to_owned(),
4344 message: message.to_owned(),
4345 }
4346}
4347
4348fn append_package_inputs(
4349 inputs: &mut Vec<(PathBuf, PathBuf)>,
4350 packages: &HashMap<String, MetadataPackage>,
4351 closure: BTreeSet<String>,
4352 workspace_root: &Path,
4353) -> Result<(), WasmBuildError> {
4354 for id in closure {
4355 let Some(package) = packages.get(&id) else {
4356 return Err(invalid_metadata(&format!(
4357 "resolved package `{id}` is missing"
4358 )));
4359 };
4360 if !package.is_local {
4361 continue;
4362 }
4363 let root = package.manifest_path.parent().ok_or_else(|| {
4364 invalid_metadata(&format!(
4365 "package `{}` manifest has no parent",
4366 package.name
4367 ))
4368 })?;
4369 let relative_manifest = package
4370 .manifest_path
4371 .strip_prefix(workspace_root)
4372 .unwrap_or(&package.manifest_path);
4373 let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
4374 .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
4375 inputs.push((label, root.to_owned()));
4376 }
4377 Ok(())
4378}
4379
4380fn append_additional_inputs(
4381 inputs: &mut Vec<(PathBuf, PathBuf)>,
4382 spec: &WasmBuildSpec,
4383 workspace_root: &Path,
4384) {
4385 for additional in &spec.additional_inputs {
4386 let path = if additional.is_absolute() {
4387 additional.clone()
4388 } else {
4389 workspace_root.join(additional)
4390 };
4391 inputs.push((PathBuf::from("additional").join(additional), path));
4392 }
4393}
4394
4395fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
4396 let mut exclusions = vec![
4397 spec.target_dir.clone(),
4398 spec.workspace_root.join("target"),
4399 spec.workspace_root.join(".git"),
4400 ];
4401 if let Some(shared_target) = shared_incremental_target(spec) {
4402 exclusions.push(shared_target);
4403 }
4404 for (_, path) in inputs {
4405 if path.is_dir() {
4406 exclusions.push(path.join("target"));
4407 exclusions.push(path.join(".git"));
4408 }
4409 }
4410 exclusions
4411}
4412
4413fn validate_shared_incremental_target_boundary(
4414 spec: &WasmBuildSpec,
4415 inputs: &[(PathBuf, PathBuf)],
4416) -> Result<(), WasmBuildError> {
4417 let Some(shared_target) = shared_incremental_target(spec) else {
4418 return Ok(());
4419 };
4420 let shared_target =
4421 canonicalize_allow_missing(&shared_target).map_err(|source| WasmBuildError::Io {
4422 operation: "resolve shared incremental Cargo target boundary",
4423 path: shared_target.clone(),
4424 source,
4425 })?;
4426 let exact_entries = spec.target_dir.join(".ic-testkit/wasm-targets");
4427 let exact_entries =
4428 canonicalize_allow_missing(&exact_entries).map_err(|source| WasmBuildError::Io {
4429 operation: "resolve exact Wasm cache boundary",
4430 path: exact_entries,
4431 source,
4432 })?;
4433 if shared_target.starts_with(&exact_entries)
4436 || (exact_entries.starts_with(&shared_target)
4437 && !exact_entries.starts_with(shared_target.join(".ic-testkit")))
4438 {
4439 return Err(WasmBuildError::InvalidSpec {
4440 message: "shared incremental target must not overlap removable exact Wasm cache state"
4441 .to_owned(),
4442 });
4443 }
4444 let resolved_inputs = inputs
4445 .iter()
4446 .map(|(_, input)| {
4447 let canonical = input.canonicalize().map_err(|source| WasmBuildError::Io {
4448 operation: "resolve Cargo input boundary",
4449 path: input.clone(),
4450 source,
4451 })?;
4452 let metadata = fs::metadata(&canonical).map_err(|source| WasmBuildError::Io {
4453 operation: "inspect Cargo input boundary",
4454 path: canonical.clone(),
4455 source,
4456 })?;
4457 Ok((canonical, metadata.is_dir()))
4458 })
4459 .collect::<Result<Vec<_>, WasmBuildError>>()?;
4460 let safe_generated_roots = std::iter::once(spec.target_dir.clone())
4461 .chain(std::iter::once(spec.workspace_root.join("target")))
4462 .chain(
4463 inputs
4464 .iter()
4465 .filter(|(_, path)| path.is_dir())
4466 .map(|(_, path)| path.join("target")),
4467 )
4468 .filter_map(|path| canonicalize_allow_missing(&path).ok())
4469 .filter(|root| {
4470 !resolved_inputs
4471 .iter()
4472 .any(|(input, _is_directory)| input.starts_with(root))
4473 })
4474 .collect::<Vec<_>>();
4475 if safe_generated_roots
4476 .iter()
4477 .any(|root| shared_target.starts_with(root))
4478 {
4479 return Ok(());
4480 }
4481
4482 for (input, is_directory) in resolved_inputs {
4483 if shared_target == input
4484 || (is_directory && shared_target.starts_with(&input))
4485 || input.starts_with(&shared_target)
4486 {
4487 return Err(WasmBuildError::InvalidSpec {
4488 message: format!(
4489 "shared incremental target {} must not overlap exact Cargo inputs unless it is inside a generated target directory",
4490 shared_target.display()
4491 ),
4492 });
4493 }
4494 }
4495 Ok(())
4496}
4497
4498pub(super) fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
4499 let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
4500 return None;
4501 };
4502 Some(if target_dir.is_absolute() {
4503 target_dir.clone()
4504 } else {
4505 spec.workspace_root.join(target_dir)
4506 })
4507}
4508
4509fn shared_incremental_target_exists(
4510 spec: &WasmBuildSpec,
4511 operation: &'static str,
4512) -> Result<bool, WasmBuildError> {
4513 let target_dir =
4514 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
4515 message: "shared incremental target is not configured".to_owned(),
4516 })?;
4517 match fs::symlink_metadata(&target_dir) {
4518 Ok(metadata) if metadata.is_dir() => Ok(true),
4519 Ok(_) => Err(WasmBuildError::InvalidSpec {
4520 message: format!(
4521 "shared incremental Cargo target {} must be a directory",
4522 target_dir.display()
4523 ),
4524 }),
4525 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
4526 Err(source) => Err(WasmBuildError::Io {
4527 operation,
4528 path: target_dir,
4529 source,
4530 }),
4531 }
4532}
4533
4534fn effective_environment(spec: &WasmBuildSpec) -> BTreeMap<OsString, Option<OsString>> {
4535 let mut names = spec.inherited_env.clone();
4536 names.extend(AUTOMATIC_ENVIRONMENT.iter().map(OsString::from));
4537 let mut environment = names
4538 .into_iter()
4539 .map(|name| {
4540 let value = std::env::var_os(&name);
4541 (name, value)
4542 })
4543 .collect::<BTreeMap<_, _>>();
4544 for (key, value) in &spec.extra_env {
4545 environment.insert(key.clone(), Some(value.clone()));
4546 }
4547 environment
4548}
4549
4550fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
4551 for (key, value) in &spec.extra_env {
4552 command.env(key, value);
4553 }
4554}
4555
4556fn run_cargo_build(
4557 spec: &WasmBuildSpec,
4558 build_target_dir: &Path,
4559 progress: &mut ProgressReporter<'_>,
4560) -> Result<(), WasmBuildError> {
4561 let absolute_target_dir =
4562 canonicalize_allow_missing(build_target_dir).map_err(|source| WasmBuildError::Io {
4563 operation: "resolve Cargo build target directory",
4564 path: build_target_dir.to_owned(),
4565 source,
4566 })?;
4567 let mut command = Command::new(&spec.cargo_program);
4568 command
4569 .current_dir(&spec.workspace_root)
4570 .env("CARGO_TARGET_DIR", absolute_target_dir)
4571 .args(["build", "--target", &spec.target])
4572 .args(&spec.cargo_profile_args);
4573 apply_command_environment(&mut command, spec);
4574 for package in &spec.packages {
4575 command.args(["-p", package]);
4576 }
4577
4578 if !progress.is_observed() {
4579 let output = command
4580 .output()
4581 .map_err(|source| WasmBuildError::CommandSpawn {
4582 phase: WasmBuildPhase::CargoBuild,
4583 program: spec.cargo_program.clone(),
4584 source,
4585 })?;
4586 return ensure_command_success(WasmBuildPhase::CargoBuild, output).map(|_| ());
4587 }
4588
4589 run_observed_cargo_build(spec, build_target_dir, command, progress)
4590}
4591
4592fn run_observed_cargo_build(
4593 spec: &WasmBuildSpec,
4594 build_target_dir: &Path,
4595 mut command: Command,
4596 progress: &mut ProgressReporter<'_>,
4597) -> Result<(), WasmBuildError> {
4598 command.stdout(Stdio::piped()).stderr(Stdio::piped());
4599 let started = Instant::now();
4600 let child = command
4601 .spawn()
4602 .map_err(|source| WasmBuildError::CommandSpawn {
4603 phase: WasmBuildPhase::CargoBuild,
4604 program: spec.cargo_program.clone(),
4605 source,
4606 })?;
4607 let mut child = ObservedChild::new(child);
4608 progress.emit(WasmBuildProgressEvent::CargoStarted {
4609 target_dir: build_target_dir.to_owned(),
4610 });
4611
4612 let stdout = child
4613 .child_mut()
4614 .stdout
4615 .take()
4616 .expect("Cargo stdout must be piped");
4617 let stderr = child
4618 .child_mut()
4619 .stderr
4620 .take()
4621 .expect("Cargo stderr must be piped");
4622 let (sender, chunks) = mpsc::channel();
4623 let stdout_sender = sender.clone();
4624 let stdout_reader = thread::spawn(move || {
4625 read_process_output(stdout, WasmBuildOutputStream::Stdout, stdout_sender)
4626 });
4627 let stderr_reader =
4628 thread::spawn(move || read_process_output(stderr, WasmBuildOutputStream::Stderr, sender));
4629
4630 let captured = capture_observed_cargo_output(chunks, progress, started);
4631
4632 let status = child.wait().map_err(|source| WasmBuildError::Io {
4633 operation: "wait for observed cargo build",
4634 path: PathBuf::from(&spec.cargo_program),
4635 source,
4636 })?;
4637 join_output_reader(
4638 stdout_reader,
4639 "read observed cargo stdout",
4640 &spec.cargo_program,
4641 )?;
4642 join_output_reader(
4643 stderr_reader,
4644 "read observed cargo stderr",
4645 &spec.cargo_program,
4646 )?;
4647 let elapsed = started.elapsed();
4648 progress.emit(WasmBuildProgressEvent::CargoFinished {
4649 success: status.success(),
4650 code: status.code(),
4651 elapsed,
4652 });
4653
4654 ensure_command_success(
4655 WasmBuildPhase::CargoBuild,
4656 Output {
4657 status,
4658 stdout: captured.stdout,
4659 stderr: captured.stderr,
4660 },
4661 )
4662 .map(|_| ())
4663}
4664
4665struct CapturedProcessOutput {
4666 stdout: Vec<u8>,
4667 stderr: Vec<u8>,
4668}
4669
4670fn capture_observed_cargo_output(
4671 chunks: mpsc::Receiver<ProcessOutputChunk>,
4672 progress: &mut ProgressReporter<'_>,
4673 started: Instant,
4674) -> CapturedProcessOutput {
4675 let mut stdout = Vec::new();
4676 let mut stderr = Vec::new();
4677 loop {
4678 let message = match progress.heartbeat_due_in() {
4679 Some(wait) => match chunks.recv_timeout(wait) {
4680 Ok(chunk) => Some(chunk),
4681 Err(RecvTimeoutError::Timeout) => {
4682 progress.emit_heartbeat(WasmBuildProgressPhase::CargoBuild, started.elapsed());
4683 None
4684 }
4685 Err(RecvTimeoutError::Disconnected) => break,
4686 },
4687 None => match chunks.recv() {
4688 Ok(chunk) => Some(chunk),
4689 Err(_) => break,
4690 },
4691 };
4692 let Some(chunk) = message else {
4693 continue;
4694 };
4695 match chunk.stream {
4696 WasmBuildOutputStream::Stdout => stdout.extend_from_slice(&chunk.bytes),
4697 WasmBuildOutputStream::Stderr => stderr.extend_from_slice(&chunk.bytes),
4698 }
4699 if progress.config.emit_cargo_output {
4700 progress.emit(WasmBuildProgressEvent::CargoOutput {
4701 stream: chunk.stream,
4702 bytes: chunk.bytes,
4703 });
4704 }
4705 }
4706 CapturedProcessOutput { stdout, stderr }
4707}
4708
4709#[derive(Debug)]
4710struct ProcessOutputChunk {
4711 stream: WasmBuildOutputStream,
4712 bytes: Vec<u8>,
4713}
4714
4715fn read_process_output<R: io::Read>(
4716 mut reader: R,
4717 stream: WasmBuildOutputStream,
4718 sender: mpsc::Sender<ProcessOutputChunk>,
4719) -> io::Result<()> {
4720 let mut buffer = [0_u8; 8 * 1024];
4721 loop {
4722 let count = match reader.read(&mut buffer) {
4723 Ok(count) => count,
4724 Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
4725 Err(error) => return Err(error),
4726 };
4727 if count == 0 {
4728 return Ok(());
4729 }
4730 if sender
4731 .send(ProcessOutputChunk {
4732 stream,
4733 bytes: buffer[..count].to_vec(),
4734 })
4735 .is_err()
4736 {
4737 return Ok(());
4738 }
4739 }
4740}
4741
4742fn join_output_reader(
4743 reader: thread::JoinHandle<io::Result<()>>,
4744 operation: &'static str,
4745 cargo_program: &OsStr,
4746) -> Result<(), WasmBuildError> {
4747 let result = reader.join().map_err(|_| WasmBuildError::Io {
4748 operation,
4749 path: PathBuf::from(cargo_program),
4750 source: io::Error::other("Cargo output reader panicked"),
4751 })?;
4752 result.map_err(|source| WasmBuildError::Io {
4753 operation,
4754 path: PathBuf::from(cargo_program),
4755 source,
4756 })
4757}
4758
4759struct ObservedChild(Option<Child>);
4760
4761impl ObservedChild {
4762 const fn new(child: Child) -> Self {
4763 Self(Some(child))
4764 }
4765
4766 const fn child_mut(&mut self) -> &mut Child {
4767 self.0.as_mut().expect("observed child must be present")
4768 }
4769
4770 fn wait(&mut self) -> io::Result<ExitStatus> {
4771 let status = self.child_mut().wait()?;
4772 self.0.take();
4773 Ok(status)
4774 }
4775}
4776
4777impl Drop for ObservedChild {
4778 fn drop(&mut self) {
4779 if let Some(mut child) = self.0.take() {
4780 let _ = child.kill();
4781 let _ = child.wait();
4782 }
4783 }
4784}
4785
4786fn ensure_command_success(phase: WasmBuildPhase, output: Output) -> Result<Output, WasmBuildError> {
4787 if output.status.success() {
4788 return Ok(output);
4789 }
4790 Err(WasmBuildError::CommandFailed {
4791 phase,
4792 status: output.status,
4793 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
4794 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
4795 })
4796}
4797
4798fn expected_artifacts(spec: &WasmBuildSpec, target_dir: &Path) -> Vec<PathBuf> {
4799 let mut packages = spec.packages.iter().map(String::as_str).collect::<Vec<_>>();
4800 packages.sort_unstable();
4801 packages.dedup();
4802 packages
4803 .into_iter()
4804 .map(|package| {
4805 if spec.target == DEFAULT_TARGET {
4806 wasm_path(target_dir, package, &spec.profile_target_dir)
4807 } else {
4808 target_dir
4809 .join(&spec.target)
4810 .join(&spec.profile_target_dir)
4811 .join(format!("{package}.wasm"))
4812 }
4813 })
4814 .collect()
4815}
4816
4817fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
4818 spec.target_dir
4819 .join(".ic-testkit/wasm-targets")
4820 .join(fingerprint.to_hex())
4821}
4822
4823fn artifact_set_matches(artifacts: &[PathBuf], fingerprint: InputDigest) -> bool {
4824 artifacts.iter().all(|path| {
4825 fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
4826 && cache_stamp_matches(path, fingerprint)
4827 })
4828}
4829
4830fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
4831 artifacts
4832 .iter()
4833 .filter(|path| {
4834 fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
4835 })
4836 .cloned()
4837 .collect()
4838}
4839
4840fn cache_stamp_matches(artifact: &Path, fingerprint: InputDigest) -> bool {
4841 let stamp_path = artifact_stamp_path(artifact);
4842 let Ok(stamp) = fs::read_to_string(stamp_path) else {
4843 return false;
4844 };
4845 if !stamp.starts_with(&artifact_stamp_header(fingerprint)) {
4848 return false;
4849 }
4850 let Ok(expected) = artifact_stamp_contents(artifact, fingerprint) else {
4851 return false;
4852 };
4853 stamp == expected
4854}
4855
4856fn artifact_stamp_path(artifact: &Path) -> PathBuf {
4857 let mut name = artifact
4858 .file_name()
4859 .map_or_else(|| OsString::from("artifact"), OsString::from);
4860 name.push(".ic-testkit-build");
4861 artifact.with_file_name(name)
4862}
4863
4864fn artifact_stamp_header(fingerprint: InputDigest) -> String {
4865 format!("{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\n")
4866}
4867
4868fn artifact_stamp_contents(artifact: &Path, fingerprint: InputDigest) -> io::Result<String> {
4869 let (_, artifact_digest) = digest_file("wasm-artifact-v1", artifact)?;
4870 Ok(format!(
4871 "{}artifact-sha256:{artifact_digest}\n",
4872 artifact_stamp_header(fingerprint),
4873 ))
4874}
4875
4876fn publish_artifact_stamps(
4877 artifacts: &[PathBuf],
4878 fingerprint: InputDigest,
4879) -> Result<(), WasmBuildError> {
4880 for artifact in artifacts {
4881 let stamp_path = artifact_stamp_path(artifact);
4882 let stamp = artifact_stamp_contents(artifact, fingerprint).map_err(|source| {
4883 WasmBuildError::Io {
4884 operation: "hash built Wasm artifact",
4885 path: artifact.clone(),
4886 source,
4887 }
4888 })?;
4889 write_atomic(&stamp_path, stamp.as_bytes()).map_err(|source| WasmBuildError::Io {
4890 operation: "publish Wasm build stamp",
4891 path: stamp_path,
4892 source,
4893 })?;
4894 }
4895 Ok(())
4896}
4897
4898fn materialize_artifacts(
4899 cached_artifacts: &[PathBuf],
4900 artifacts: &[PathBuf],
4901 fingerprint: InputDigest,
4902) -> Result<(), WasmBuildError> {
4903 for (cached, artifact) in cached_artifacts.iter().zip(artifacts) {
4904 copy_file_atomic(cached, artifact).map_err(|source| WasmBuildError::Io {
4905 operation: "publish Wasm artifact",
4906 path: artifact.clone(),
4907 source,
4908 })?;
4909 }
4910 publish_artifact_stamps(artifacts, fingerprint)
4911}
4912
4913fn copy_wasm_artifacts(
4914 source_artifacts: &[PathBuf],
4915 cached_artifacts: &[PathBuf],
4916) -> Result<(), WasmBuildError> {
4917 for (source, cached) in source_artifacts.iter().zip(cached_artifacts) {
4918 copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
4919 operation: "cache shared-incremental Wasm artifact",
4920 path: cached.clone(),
4921 source: source_error,
4922 })?;
4923 }
4924 Ok(())
4925}
4926
4927fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
4928 fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
4929 operation,
4930 path: path.to_owned(),
4931 source,
4932 })
4933}
4934
4935fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
4936 if path.exists() {
4937 inputs.push((PathBuf::from(label), path));
4938 }
4939}
4940
4941fn required_string(value: &Value, field: &str) -> Result<String, String> {
4942 value
4943 .get(field)
4944 .and_then(Value::as_str)
4945 .map(str::to_owned)
4946 .ok_or_else(|| format!("Cargo metadata field `{field}` is missing"))
4947}
4948
4949fn optional_string(value: &Value, field: &str) -> Result<Option<String>, String> {
4950 match value.get(field) {
4951 None | Some(Value::Null) => Ok(None),
4952 Some(Value::String(value)) => Ok(Some(value.clone())),
4953 Some(_) => Err(format!(
4954 "Cargo metadata field `{field}` is not a string or null"
4955 )),
4956 }
4957}
4958
4959fn invalid_metadata(message: &str) -> WasmBuildError {
4960 WasmBuildError::InvalidMetadata {
4961 message: message.to_owned(),
4962 }
4963}
4964
4965impl WasmBuildError {
4966 fn indicates_input_change(&self) -> bool {
4967 match self {
4968 Self::InputsChangedDuringAcquisition { .. } => true,
4969 Self::FailedBuildCleanup { build_error, .. } => build_error.indicates_input_change(),
4970 _ => false,
4971 }
4972 }
4973}
4974
4975impl std::fmt::Display for WasmBuildPhase {
4976 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4977 formatter.write_str(match self {
4978 Self::CargoMetadata => "cargo metadata",
4979 Self::CargoIdentity => "Cargo identity",
4980 Self::RustcIdentity => "Rust compiler identity",
4981 Self::CargoBuild => "cargo build",
4982 })
4983 }
4984}
4985
4986impl std::fmt::Display for WasmBuildProgressPhase {
4987 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4988 formatter.write_str(match self {
4989 Self::ExactCacheLock => "exact cache lock",
4990 Self::CargoIdentity => "Cargo identity",
4991 Self::RustcIdentity => "Rust compiler identity",
4992 Self::CargoMetadata => "Cargo metadata",
4993 Self::InputDiscovery => "input discovery",
4994 Self::ContentHashing => "content hashing",
4995 Self::SharedTargetLock => "shared target lock",
4996 Self::SharedTargetMaintenance => "shared target maintenance",
4997 Self::CargoBuild => "Cargo build",
4998 Self::ArtifactPublication => "artifact publication",
4999 Self::ExactCacheMaintenance => "exact cache maintenance",
5000 })
5001 }
5002}
5003
5004impl std::fmt::Display for WasmBuildError {
5005 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5006 match self {
5007 Self::InvalidSpec { message } => {
5008 write!(formatter, "invalid Wasm build spec: {message}")
5009 }
5010 Self::Io {
5011 operation,
5012 path,
5013 source,
5014 } => write!(
5015 formatter,
5016 "failed to {operation} at {}: {source}",
5017 path.display()
5018 ),
5019 Self::CommandSpawn {
5020 phase,
5021 program,
5022 source,
5023 } => write!(
5024 formatter,
5025 "failed to launch {phase} using `{}`: {source}",
5026 program.to_string_lossy(),
5027 ),
5028 Self::CommandFailed {
5029 phase,
5030 status,
5031 stdout,
5032 stderr,
5033 } => write!(
5034 formatter,
5035 "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
5036 ),
5037 Self::InvalidMetadata { message } => {
5038 write!(formatter, "invalid Cargo metadata: {message}")
5039 }
5040 Self::InvalidCargoConfiguration { path, message } => write!(
5041 formatter,
5042 "invalid Cargo configuration at {}: {message}",
5043 path.display(),
5044 ),
5045 Self::MissingArtifacts { paths } => write!(
5046 formatter,
5047 "cargo build succeeded without producing: {}",
5048 paths
5049 .iter()
5050 .map(|path| path.display().to_string())
5051 .collect::<Vec<_>>()
5052 .join(", "),
5053 ),
5054 Self::InputsChangedDuringAcquisition { before, after } => write!(
5055 formatter,
5056 "Wasm inputs changed during artifact acquisition: {before} -> {after}",
5057 ),
5058 Self::PreparedInputSnapshotInvalidated => formatter.write_str(
5059 "the prepared Wasm input snapshot was invalidated before artifact publication",
5060 ),
5061 Self::FailedBuildCleanup {
5062 build_error,
5063 path,
5064 source,
5065 } => write!(
5066 formatter,
5067 "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
5068 path.display(),
5069 ),
5070 }
5071 }
5072}
5073
5074impl std::error::Error for WasmBuildError {
5075 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
5076 match self {
5077 Self::Io { source, .. }
5078 | Self::CommandSpawn { source, .. }
5079 | Self::FailedBuildCleanup { source, .. } => Some(source),
5080 _ => None,
5081 }
5082 }
5083}
5084
5085#[cfg(test)]
5086mod tests;