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