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