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