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