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