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