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