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