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 _ = self
1511 .reader_reuses
1512 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
1513 Some(current.saturating_add(1))
1514 });
1515 Some(snapshot.clone())
1516 }
1517
1518 pub(super) const fn specification_count(&self) -> usize {
1519 self.snapshots.len()
1520 }
1521
1522 pub(super) const fn preparation_metrics(&self) -> WasmBuildBatchInputMetrics {
1523 self.preparation_metrics
1524 }
1525
1526 pub(super) const fn preparation_timings(&self) -> WasmInputResolutionTimings {
1527 self.preparation_timings
1528 }
1529
1530 pub(super) fn reader_reuses(&self) -> usize {
1531 self.reader_reuses.load(Ordering::Relaxed)
1532 }
1533
1534 pub(super) fn is_invalidated(&self) -> bool {
1535 *self
1536 .invalidation
1537 .read()
1538 .unwrap_or_else(std::sync::PoisonError::into_inner)
1539 }
1540
1541 fn invalidate(&self) {
1542 *self
1543 .invalidation
1544 .write()
1545 .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
1546 }
1547
1548 fn invalidation(&self) -> Arc<RwLock<bool>> {
1549 Arc::clone(&self.invalidation)
1550 }
1551}
1552
1553impl<'a, 'session> WasmBuildBatchInputResolver<'a, 'session> {
1554 pub(super) fn new(specs: &'a [WasmBuildSpec]) -> Self {
1555 Self::create(specs, None, None)
1556 }
1557
1558 pub(super) fn with_session(
1559 specs: &'a [WasmBuildSpec],
1560 session: &'session mut WasmBuildSessionState,
1561 ) -> Self {
1562 Self::create(specs, Some(session), None)
1563 }
1564
1565 pub(super) fn with_snapshot(
1566 specs: &'a [WasmBuildSpec],
1567 snapshot: &'session WasmBuildInputSnapshotState,
1568 ) -> Self {
1569 Self::create(specs, None, Some(snapshot))
1570 }
1571
1572 fn create(
1573 specs: &'a [WasmBuildSpec],
1574 mut session: Option<&'session mut WasmBuildSessionState>,
1575 snapshot: Option<&'session WasmBuildInputSnapshotState>,
1576 ) -> Self {
1577 let mut keys = Vec::<BatchResolutionKey>::new();
1578 let mut groups = Vec::<BatchResolutionGroup>::new();
1579 let mut group_by_index = Vec::with_capacity(specs.len());
1580 for (index, spec) in specs.iter().enumerate() {
1581 let key = BatchResolutionKey::for_spec(spec);
1582 let group = keys
1583 .iter()
1584 .position(|candidate| *candidate == key)
1585 .unwrap_or_else(|| {
1586 keys.push(key);
1587 groups.push(BatchResolutionGroup {
1588 indexes: Vec::new(),
1589 });
1590 groups.len() - 1
1591 });
1592 groups[group].indexes.push(index);
1593 group_by_index.push(group);
1594 }
1595 let mut metrics = WasmBuildBatchInputMetrics::default();
1596 let resolved = specs
1597 .iter()
1598 .map(|spec| {
1599 let session_reused = session
1600 .as_deref_mut()
1601 .and_then(|session| session.reuse(spec));
1602 if session_reused.is_some() {
1603 metrics.session_reuses = metrics.session_reuses.saturating_add(1);
1604 return session_reused.map(Ok);
1605 }
1606 if let Some(snapshot) = snapshot {
1607 let reused = snapshot
1608 .reuse(spec)
1609 .expect("prepared input snapshot must contain every reader specification");
1610 metrics.prepared_reuses = metrics.prepared_reuses.saturating_add(1);
1611 return Some(Ok(reused));
1612 }
1613 None
1614 })
1615 .collect();
1616 Self {
1617 specs,
1618 groups,
1619 group_by_index,
1620 resolved,
1621 session,
1622 snapshot,
1623 metrics,
1624 }
1625 }
1626
1627 pub(super) const fn metrics(&self) -> WasmBuildBatchInputMetrics {
1628 self.metrics
1629 }
1630
1631 pub(super) fn invalidate_source_lease(&mut self) {
1632 if let Some(session) = self.session.as_deref_mut() {
1633 session.invalidate();
1634 for resolved in &mut self.resolved {
1638 *resolved = None;
1639 }
1640 self.session = None;
1641 }
1642 if let Some(snapshot) = self.snapshot {
1643 snapshot.invalidate();
1644 }
1645 }
1646
1647 pub(super) const fn assumes_sources_immutable(&self) -> bool {
1648 self.session.is_some() || self.snapshot.is_some()
1649 }
1650
1651 pub(super) fn prepared_invalidation(&self) -> Option<Arc<RwLock<bool>>> {
1652 self.snapshot.map(WasmBuildInputSnapshotState::invalidation)
1653 }
1654
1655 fn resolve(
1656 &mut self,
1657 index: usize,
1658 progress: &mut ProgressReporter<'_>,
1659 ) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1660 if self.resolved[index].is_none() {
1661 self.resolve_group(index, progress)?;
1662 }
1663 self.resolved[index]
1664 .take()
1665 .expect("resolved batch input must be populated")
1666 }
1667
1668 fn resolve_group(
1669 &mut self,
1670 active_index: usize,
1671 progress: &mut ProgressReporter<'_>,
1672 ) -> Result<(), WasmBuildError> {
1673 let total_started = Instant::now();
1674 let indexes = self.groups[self.group_by_index[active_index]]
1675 .indexes
1676 .clone();
1677 let active = &self.specs[active_index];
1678
1679 let (cargo_identity, rustc_identity, tool_identity) =
1680 resolve_batch_tool_identity(active, progress)?;
1681
1682 let metadata_started = Instant::now();
1683 let metadata = progress.run_phase(WasmBuildProgressPhase::CargoMetadata, || {
1684 cargo_metadata(active)
1685 })?;
1686 let cargo_metadata = metadata_started.elapsed();
1687
1688 let (discovered, input_discovery) =
1689 self.discover_group_inputs(indexes, &metadata, progress);
1690
1691 let hashing_started = Instant::now();
1692 let mut batch_digest_cache = LabeledPathDigestCache::default();
1693 let digest_cache = self
1694 .session
1695 .as_deref_mut()
1696 .map_or(&mut batch_digest_cache, |session| &mut session.digest_cache);
1697 let workspace_root = active.workspace_root.clone();
1698 let resolved_inputs = progress.run_phase(WasmBuildProgressPhase::ContentHashing, || {
1699 discovered
1700 .into_iter()
1701 .map(|(index, inputs, exclusions)| {
1702 let (input_digest, validation_digest) = digest_resolved_local_inputs(
1703 &inputs,
1704 &exclusions,
1705 digest_cache,
1706 &workspace_root,
1707 "hash batched Wasm build inputs",
1708 "hash batched semantic Wasm build inputs",
1709 )?;
1710 Ok::<_, WasmBuildError>((
1711 index,
1712 inputs.validation_inputs,
1713 exclusions,
1714 input_digest,
1715 validation_digest,
1716 ))
1717 })
1718 .collect::<Result<Vec<_>, _>>()
1719 })?;
1720 let content_hashing = hashing_started.elapsed();
1721 let timings = WasmInputResolutionTimings {
1722 tool_identity,
1723 cargo_metadata,
1724 input_discovery,
1725 content_hashing,
1726 total: total_started.elapsed(),
1727 };
1728 let resolved_count = resolved_inputs.len();
1729 if resolved_count > 0 {
1730 self.metrics.runs += 1;
1731 self.metrics.reuses += resolved_count.saturating_sub(1);
1732 }
1733 let timing_index = resolved_inputs
1734 .iter()
1735 .any(|(index, ..)| *index == active_index)
1736 .then_some(active_index)
1737 .or_else(|| resolved_inputs.first().map(|(index, ..)| *index));
1738 for (index, inputs, exclusions, input_digest, validation_digest) in resolved_inputs {
1739 let spec = &self.specs[index];
1740 let resolved = ResolvedCargoBuildInputs {
1741 fingerprint: finish_build_fingerprint(
1742 spec,
1743 &cargo_identity,
1744 &rustc_identity,
1745 input_digest,
1746 ),
1747 input_digest,
1748 validation_digest,
1749 inputs: inputs
1750 .into_iter()
1751 .map(|(label, path)| CargoBuildInput { label, path })
1752 .collect(),
1753 exclusions,
1754 timings: if Some(index) == timing_index {
1755 timings
1756 } else {
1757 WasmInputResolutionTimings::default()
1758 },
1759 };
1760 if let Some(session) = self.session.as_deref_mut() {
1761 session.remember(spec, &resolved);
1762 }
1763 self.resolved[index] = Some(Ok(resolved));
1764 }
1765 Ok(())
1766 }
1767
1768 fn discover_group_inputs(
1769 &mut self,
1770 indexes: Vec<usize>,
1771 metadata: &Value,
1772 progress: &mut ProgressReporter<'_>,
1773 ) -> (Vec<(usize, ResolvedLocalInputs, Vec<PathBuf>)>, Duration) {
1774 let started = Instant::now();
1775 let pending = indexes
1776 .into_iter()
1777 .filter(|index| {
1778 self.resolved[*index].is_none() && validate_spec(&self.specs[*index]).is_ok()
1779 })
1780 .collect::<Vec<_>>();
1781 let results = progress.run_phase(WasmBuildProgressPhase::InputDiscovery, || {
1782 pending
1783 .into_iter()
1784 .map(|index| {
1785 let spec = &self.specs[index];
1786 let result = (|| {
1787 let inputs = resolve_local_inputs(spec, metadata)?;
1788 validate_shared_incremental_target_boundary(
1789 spec,
1790 &inputs.validation_inputs,
1791 )?;
1792 let exclusions = source_exclusions(spec, &inputs.validation_inputs);
1793 Ok::<_, WasmBuildError>((inputs, exclusions))
1794 })();
1795 (index, result)
1796 })
1797 .collect::<Vec<_>>()
1798 });
1799 let mut discovered = Vec::new();
1800 for (index, result) in results {
1801 match result {
1802 Ok((inputs, exclusions)) => discovered.push((index, inputs, exclusions)),
1803 Err(error) => self.resolved[index] = Some(Err(error)),
1804 }
1805 }
1806 (discovered, started.elapsed())
1807 }
1808}
1809
1810fn resolve_batch_tool_identity(
1811 spec: &WasmBuildSpec,
1812 progress: &mut ProgressReporter<'_>,
1813) -> Result<(Vec<u8>, Vec<u8>, Duration), WasmBuildError> {
1814 let started = Instant::now();
1815 let cargo_identity = progress.run_phase(WasmBuildProgressPhase::CargoIdentity, || {
1816 command_identity(
1817 spec,
1818 WasmBuildPhase::CargoIdentity,
1819 &spec.cargo_program,
1820 &["--version", "--verbose"],
1821 )
1822 })?;
1823 let rustc_program = spec
1824 .extra_env
1825 .get(OsStr::new("RUSTC"))
1826 .unwrap_or(&spec.rustc_program);
1827 let rustc_identity = progress.run_phase(WasmBuildProgressPhase::RustcIdentity, || {
1828 command_identity(spec, WasmBuildPhase::RustcIdentity, rustc_program, &["-vV"])
1829 })?;
1830 Ok((cargo_identity, rustc_identity, started.elapsed()))
1831}
1832
1833impl BatchResolutionKey {
1834 fn for_spec(spec: &WasmBuildSpec) -> Self {
1835 Self {
1836 workspace_root: spec.workspace_root.clone(),
1837 cargo_program: spec.cargo_program.clone(),
1838 rustc_program: spec
1839 .extra_env
1840 .get(OsStr::new("RUSTC"))
1841 .unwrap_or(&spec.rustc_program)
1842 .clone(),
1843 metadata_arguments: metadata_arguments(&spec.cargo_profile_args),
1844 environment: effective_environment(spec),
1845 }
1846 }
1847}
1848
1849impl SharedIncrementalTargetInspection {
1850 #[must_use]
1852 pub fn target_dir(&self) -> &Path {
1853 &self.target_dir
1854 }
1855
1856 #[must_use]
1858 pub const fn logical_size_bytes(&self) -> u64 {
1859 self.logical_size_bytes
1860 }
1861
1862 #[must_use]
1864 pub const fn last_used(&self) -> SystemTime {
1865 self.last_used
1866 }
1867
1868 #[must_use]
1870 pub const fn lock_wait(&self) -> Duration {
1871 self.lock_wait
1872 }
1873}
1874
1875impl SharedIncrementalTargetPrunePolicy {
1876 #[must_use]
1878 pub const fn new() -> Self {
1879 Self {
1880 max_age: None,
1881 max_size_bytes: None,
1882 }
1883 }
1884
1885 #[must_use]
1887 pub const fn with_max_age(mut self, max_age: Duration) -> Self {
1888 self.max_age = Some(max_age);
1889 self
1890 }
1891
1892 #[must_use]
1894 pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
1895 self.max_size_bytes = Some(bytes);
1896 self
1897 }
1898
1899 #[must_use]
1901 pub const fn max_age(self) -> Option<Duration> {
1902 self.max_age
1903 }
1904
1905 #[must_use]
1907 pub const fn max_size_bytes(self) -> Option<u64> {
1908 self.max_size_bytes
1909 }
1910
1911 fn maintenance_identity(self) -> String {
1912 format!(
1913 "age={:?};size={:?}",
1914 self.max_age.map(|duration| duration.as_nanos()),
1915 self.max_size_bytes
1916 )
1917 }
1918}
1919
1920impl SharedIncrementalTargetMaintenanceConfig {
1921 #[must_use]
1923 pub const fn new(
1924 policy: SharedIncrementalTargetPrunePolicy,
1925 minimum_interval: Duration,
1926 ) -> Self {
1927 Self {
1928 policy,
1929 minimum_interval,
1930 failure_mode: SharedIncrementalTargetMaintenanceFailureMode::Strict,
1931 }
1932 }
1933
1934 #[must_use]
1936 pub const fn with_failure_mode(
1937 mut self,
1938 failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
1939 ) -> Self {
1940 self.failure_mode = failure_mode;
1941 self
1942 }
1943
1944 #[must_use]
1946 pub const fn policy(self) -> SharedIncrementalTargetPrunePolicy {
1947 self.policy
1948 }
1949
1950 #[must_use]
1952 pub const fn minimum_interval(self) -> Duration {
1953 self.minimum_interval
1954 }
1955
1956 #[must_use]
1958 pub const fn failure_mode(self) -> SharedIncrementalTargetMaintenanceFailureMode {
1959 self.failure_mode
1960 }
1961}
1962
1963impl SharedIncrementalTargetMaintenance {
1964 #[must_use]
1966 pub fn target_dir(&self) -> &Path {
1967 &self.target_dir
1968 }
1969
1970 #[must_use]
1972 pub const fn logical_size_bytes_before(&self) -> u64 {
1973 self.logical_size_bytes_before
1974 }
1975
1976 #[must_use]
1978 pub const fn logical_size_bytes_after(&self) -> u64 {
1979 self.logical_size_bytes_after
1980 }
1981
1982 #[must_use]
1984 pub const fn last_used_before(&self) -> SystemTime {
1985 self.last_used_before
1986 }
1987
1988 #[must_use]
1990 pub const fn was_cleared(&self) -> bool {
1991 self.cleared
1992 }
1993
1994 #[must_use]
1996 pub const fn lock_wait(&self) -> Duration {
1997 self.lock_wait
1998 }
1999
2000 #[must_use]
2002 pub const fn maintenance(&self) -> Duration {
2003 self.maintenance
2004 }
2005}
2006
2007impl std::fmt::Display for SharedIncrementalTargetMaintenance {
2008 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2009 write!(
2010 formatter,
2011 "target={} action={} bytes={}=>{} lock={:?} maintenance={:?}",
2012 self.target_dir.display(),
2013 if self.cleared { "cleared" } else { "retained" },
2014 self.logical_size_bytes_before,
2015 self.logical_size_bytes_after,
2016 self.lock_wait,
2017 self.maintenance,
2018 )
2019 }
2020}
2021
2022impl SharedIncrementalTargetMaintenanceOutcome {
2023 #[must_use]
2025 pub fn target_dir(&self) -> &Path {
2026 match self {
2027 Self::Missing { target_dir }
2028 | Self::Skipped { target_dir, .. }
2029 | Self::Failed { target_dir, .. } => target_dir,
2030 Self::Performed { maintenance, .. } => maintenance.target_dir(),
2031 }
2032 }
2033
2034 #[must_use]
2036 pub const fn maintenance(&self) -> Option<&SharedIncrementalTargetMaintenance> {
2037 match self {
2038 Self::Performed { maintenance, .. } => Some(maintenance),
2039 Self::Missing { .. } | Self::Skipped { .. } | Self::Failed { .. } => None,
2040 }
2041 }
2042
2043 #[must_use]
2045 pub const fn was_performed(&self) -> bool {
2046 matches!(self, Self::Performed { .. })
2047 }
2048
2049 #[must_use]
2051 pub const fn lock_wait(&self) -> Option<Duration> {
2052 match self {
2053 Self::Missing { .. } => None,
2054 Self::Skipped { lock_wait, .. } | Self::Failed { lock_wait, .. } => Some(*lock_wait),
2055 Self::Performed { maintenance, .. } => Some(maintenance.lock_wait()),
2056 }
2057 }
2058
2059 #[must_use]
2061 pub const fn schedule_check(&self) -> Option<Duration> {
2062 match self {
2063 Self::Missing { .. } | Self::Failed { .. } => None,
2064 Self::Skipped { schedule_check, .. } | Self::Performed { schedule_check, .. } => {
2065 Some(*schedule_check)
2066 }
2067 }
2068 }
2069
2070 #[must_use]
2072 pub fn failure_message(&self) -> Option<&str> {
2073 match self {
2074 Self::Failed { message, .. } => Some(message),
2075 Self::Missing { .. } | Self::Skipped { .. } | Self::Performed { .. } => None,
2076 }
2077 }
2078}
2079
2080impl std::fmt::Display for SharedIncrementalTargetMaintenanceOutcome {
2081 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2082 match self {
2083 Self::Missing { target_dir } => {
2084 write!(formatter, "target={} action=missing", target_dir.display())
2085 }
2086 Self::Skipped {
2087 target_dir,
2088 lock_wait,
2089 schedule_check,
2090 } => write!(
2091 formatter,
2092 "target={} action=skipped lock={lock_wait:?} schedule={schedule_check:?}",
2093 target_dir.display(),
2094 ),
2095 Self::Performed {
2096 maintenance,
2097 schedule_check,
2098 } => write!(formatter, "{maintenance} schedule={schedule_check:?}"),
2099 Self::Failed {
2100 target_dir,
2101 lock_wait,
2102 message,
2103 } => write!(
2104 formatter,
2105 "target={} action=failed lock={lock_wait:?} error={message}",
2106 target_dir.display(),
2107 ),
2108 }
2109 }
2110}
2111
2112impl std::fmt::Display for WasmBuildTimings {
2113 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2114 write!(
2115 formatter,
2116 "total={:?} lock={:?} shared_lock={:?} inputs={:?} cargo={:?} maintenance={:?}",
2117 self.total,
2118 self.lock_wait,
2119 self.shared_incremental_lock_wait,
2120 self.input_resolution.total,
2121 self.cargo_build,
2122 self.cache_maintenance,
2123 )
2124 }
2125}
2126
2127impl std::fmt::Display for WasmBuildOutcome {
2128 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2129 let state = if self.is_reused() { "reused" } else { "built" };
2130 write!(
2131 formatter,
2132 "{state} fingerprint={} artifacts={} {}",
2133 self.record().fingerprint,
2134 self.record().artifacts.len(),
2135 self.record().timings,
2136 )?;
2137 if let Some(maintenance) = self.record().shared_incremental_maintenance() {
2138 write!(formatter, " shared_maintenance=({maintenance})")?;
2139 }
2140 Ok(())
2141 }
2142}
2143
2144pub fn resolve_cargo_build_inputs(
2149 spec: &WasmBuildSpec,
2150) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2151 validate_spec(spec)?;
2152 build_fingerprint(spec)
2153}
2154
2155pub fn inspect_shared_incremental_target(
2160 spec: &WasmBuildSpec,
2161) -> Result<Option<SharedIncrementalTargetInspection>, WasmBuildError> {
2162 if !shared_incremental_target_exists(spec, "inspect shared incremental Cargo target")? {
2163 return Ok(None);
2164 }
2165
2166 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2167 let logical_size_bytes =
2168 directory_logical_size(&canonical).map_err(|source| WasmBuildError::Io {
2169 operation: "measure shared incremental Cargo target",
2170 path: canonical.clone(),
2171 source,
2172 })?;
2173 let last_used = cache_entry_last_used(&canonical).map_err(|source| WasmBuildError::Io {
2174 operation: "read shared incremental Cargo target use time",
2175 path: canonical.clone(),
2176 source,
2177 })?;
2178 Ok(Some(SharedIncrementalTargetInspection {
2179 target_dir: canonical,
2180 logical_size_bytes,
2181 last_used,
2182 lock_wait,
2183 }))
2184}
2185
2186pub fn maintain_shared_incremental_target(
2200 spec: &WasmBuildSpec,
2201 policy: SharedIncrementalTargetPrunePolicy,
2202) -> Result<Option<SharedIncrementalTargetMaintenance>, WasmBuildError> {
2203 if !shared_incremental_target_exists(
2204 spec,
2205 "inspect shared incremental Cargo target before maintenance",
2206 )? {
2207 return Ok(None);
2208 }
2209
2210 let _ = resolve_cargo_build_inputs(spec)?;
2214 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2215 maintain_shared_incremental_target_locked(&canonical, policy, lock_wait).map(Some)
2216}
2217
2218pub fn maintain_shared_incremental_target_at_most_every(
2232 spec: &WasmBuildSpec,
2233 policy: SharedIncrementalTargetPrunePolicy,
2234 minimum_interval: Duration,
2235) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2236 let target_dir =
2237 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
2238 message: "shared incremental target is not configured".to_owned(),
2239 })?;
2240 if !shared_incremental_target_exists(
2241 spec,
2242 "inspect shared incremental Cargo target before scheduled maintenance",
2243 )? {
2244 return Ok(SharedIncrementalTargetMaintenanceOutcome::Missing { target_dir });
2245 }
2246
2247 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2248 let schedule = schedule_shared_incremental_target_maintenance(
2249 &canonical,
2250 policy,
2251 minimum_interval,
2252 lock_wait,
2253 )?;
2254 let schedule = match schedule {
2255 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => return Ok(outcome),
2256 SharedIncrementalTargetMaintenanceSchedule::Due(due) => due,
2257 };
2258
2259 let _ = resolve_cargo_build_inputs(spec)?;
2262 perform_due_shared_incremental_target_maintenance(&canonical, policy, lock_wait, schedule)
2263}
2264
2265enum SharedIncrementalTargetMaintenanceSchedule {
2266 Skipped(SharedIncrementalTargetMaintenanceOutcome),
2267 Due(DueSharedIncrementalTargetMaintenance),
2268}
2269
2270struct DueSharedIncrementalTargetMaintenance {
2271 schedule_root: PathBuf,
2272 maintenance_identity: String,
2273 schedule_check: Duration,
2274}
2275
2276fn schedule_shared_incremental_target_maintenance(
2277 canonical: &Path,
2278 policy: SharedIncrementalTargetPrunePolicy,
2279 minimum_interval: Duration,
2280 lock_wait: Duration,
2281) -> Result<SharedIncrementalTargetMaintenanceSchedule, WasmBuildError> {
2282 let schedule_root = canonical.join(".ic-testkit");
2283 let maintenance_identity = policy.maintenance_identity();
2284 let schedule_started = Instant::now();
2285 let due = cache_maintenance_due(
2286 &schedule_root,
2287 Some(minimum_interval),
2288 &maintenance_identity,
2289 )
2290 .map_err(wasm_cache_fs_error)?;
2291 let schedule_check = schedule_started.elapsed();
2292 if !due {
2293 return Ok(SharedIncrementalTargetMaintenanceSchedule::Skipped(
2294 SharedIncrementalTargetMaintenanceOutcome::Skipped {
2295 target_dir: canonical.to_owned(),
2296 lock_wait,
2297 schedule_check,
2298 },
2299 ));
2300 }
2301 Ok(SharedIncrementalTargetMaintenanceSchedule::Due(
2302 DueSharedIncrementalTargetMaintenance {
2303 schedule_root,
2304 maintenance_identity,
2305 schedule_check,
2306 },
2307 ))
2308}
2309
2310fn perform_due_shared_incremental_target_maintenance(
2311 canonical: &Path,
2312 policy: SharedIncrementalTargetPrunePolicy,
2313 lock_wait: Duration,
2314 due: DueSharedIncrementalTargetMaintenance,
2315) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2316 let DueSharedIncrementalTargetMaintenance {
2317 schedule_root,
2318 maintenance_identity,
2319 schedule_check,
2320 } = due;
2321 let maintenance = maintain_shared_incremental_target_locked(canonical, policy, lock_wait)?;
2322 record_cache_maintenance(&schedule_root, &maintenance_identity).map_err(wasm_cache_fs_error)?;
2323 Ok(SharedIncrementalTargetMaintenanceOutcome::Performed {
2324 maintenance,
2325 schedule_check,
2326 })
2327}
2328
2329fn maintain_shared_incremental_target_locked(
2330 canonical: &Path,
2331 policy: SharedIncrementalTargetPrunePolicy,
2332 lock_wait: Duration,
2333) -> Result<SharedIncrementalTargetMaintenance, WasmBuildError> {
2334 let started = Instant::now();
2335 let logical_size_bytes_before =
2336 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2337 operation: "measure shared incremental Cargo target before maintenance",
2338 path: canonical.to_owned(),
2339 source,
2340 })?;
2341 let last_used_before =
2342 cache_entry_last_used(canonical).map_err(|source| WasmBuildError::Io {
2343 operation: "read shared incremental Cargo target use time before maintenance",
2344 path: canonical.to_owned(),
2345 source,
2346 })?;
2347 let expired = policy.max_age.is_some_and(|max_age| {
2348 SystemTime::now()
2349 .duration_since(last_used_before)
2350 .is_ok_and(|age| age > max_age)
2351 });
2352 let oversized = policy
2353 .max_size_bytes
2354 .is_some_and(|max_size_bytes| logical_size_bytes_before > max_size_bytes);
2355 let cleared = expired || oversized;
2356 if cleared {
2357 clear_shared_incremental_target_contents(canonical)?;
2358 record_cache_entry_use(canonical)?;
2359 }
2360 let logical_size_bytes_after = if cleared {
2361 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2362 operation: "measure shared incremental Cargo target after maintenance",
2363 path: canonical.to_owned(),
2364 source,
2365 })?
2366 } else {
2367 logical_size_bytes_before
2368 };
2369 Ok(SharedIncrementalTargetMaintenance {
2370 target_dir: canonical.to_owned(),
2371 logical_size_bytes_before,
2372 logical_size_bytes_after,
2373 last_used_before,
2374 cleared,
2375 lock_wait,
2376 maintenance: started.elapsed(),
2377 })
2378}
2379
2380fn clear_shared_incremental_target_contents(target_dir: &Path) -> Result<(), WasmBuildError> {
2381 let entries = fs::read_dir(target_dir).map_err(|source| WasmBuildError::Io {
2382 operation: "read shared incremental Cargo target for maintenance",
2383 path: target_dir.to_owned(),
2384 source,
2385 })?;
2386 for entry in entries {
2387 let path = entry
2388 .map_err(|source| WasmBuildError::Io {
2389 operation: "read shared incremental Cargo target entry for maintenance",
2390 path: target_dir.to_owned(),
2391 source,
2392 })?
2393 .path();
2394 let preserved = path
2395 .file_name()
2396 .is_some_and(|name| name == ".ic-testkit" || name == "CACHEDIR.TAG");
2397 if !preserved {
2398 remove_path_if_present(&path).map_err(|source| WasmBuildError::Io {
2399 operation: "clear shared incremental Cargo target entry",
2400 path,
2401 source,
2402 })?;
2403 }
2404 }
2405 Ok(())
2406}
2407
2408pub fn build_wasm_canisters_cached(
2415 spec: &WasmBuildSpec,
2416) -> Result<WasmBuildOutcome, WasmBuildError> {
2417 build_wasm_canisters_cached_internal(spec, &mut ProgressReporter::silent(), None)
2418}
2419
2420pub(super) fn build_wasm_canisters_cached_in_batch(
2421 spec: &WasmBuildSpec,
2422 index: usize,
2423 resolver: &mut WasmBuildBatchInputResolver<'_, '_>,
2424) -> WasmBuildBatchAttempt {
2425 let started = Instant::now();
2426 let mut progress = ProgressReporter::silent();
2427 let result = build_wasm_canisters_cached_internal(spec, &mut progress, Some((resolver, index)));
2428 if result
2429 .as_ref()
2430 .is_err_and(WasmBuildError::indicates_input_change)
2431 {
2432 resolver.invalidate_source_lease();
2433 }
2434 batch_attempt(result, &progress, started.elapsed())
2435}
2436
2437pub fn build_wasm_canisters_cached_with_progress<F>(
2446 spec: &WasmBuildSpec,
2447 config: WasmBuildProgressConfig,
2448 mut observer: F,
2449) -> Result<WasmBuildOutcome, WasmBuildError>
2450where
2451 F: FnMut(WasmBuildProgressEvent),
2452{
2453 if config.heartbeat_interval == Some(Duration::ZERO) {
2454 return Err(WasmBuildError::InvalidSpec {
2455 message: "Wasm build progress heartbeat interval must be greater than zero".to_owned(),
2456 });
2457 }
2458 build_wasm_canisters_cached_internal(
2459 spec,
2460 &mut ProgressReporter::observed(config, &mut observer),
2461 None,
2462 )
2463}
2464
2465pub(super) fn build_wasm_canisters_cached_in_batch_with_progress<F>(
2466 spec: &WasmBuildSpec,
2467 index: usize,
2468 resolver: &mut WasmBuildBatchInputResolver<'_, '_>,
2469 config: WasmBuildProgressConfig,
2470 mut observer: F,
2471) -> WasmBuildBatchAttempt
2472where
2473 F: FnMut(WasmBuildProgressEvent),
2474{
2475 if config.heartbeat_interval == Some(Duration::ZERO) {
2476 return WasmBuildBatchAttempt::invalid_spec(
2477 WasmBuildError::InvalidSpec {
2478 message: "Wasm build progress heartbeat interval must be greater than zero"
2479 .to_owned(),
2480 },
2481 Duration::ZERO,
2482 );
2483 }
2484 let started = Instant::now();
2485 let mut progress = ProgressReporter::observed(config, &mut observer);
2486 let result = build_wasm_canisters_cached_internal(spec, &mut progress, Some((resolver, index)));
2487 if result
2488 .as_ref()
2489 .is_err_and(WasmBuildError::indicates_input_change)
2490 {
2491 resolver.invalidate_source_lease();
2492 }
2493 batch_attempt(result, &progress, started.elapsed())
2494}
2495
2496fn batch_attempt(
2497 result: Result<WasmBuildOutcome, WasmBuildError>,
2498 progress: &ProgressReporter<'_>,
2499 total: Duration,
2500) -> WasmBuildBatchAttempt {
2501 let (failure_phase, failure_timings) = result.as_ref().err().map_or((None, None), |error| {
2502 let (phase, timings) = progress.failure_details(error, total);
2503 (Some(phase), Some(timings))
2504 });
2505 WasmBuildBatchAttempt {
2506 result,
2507 failure_phase,
2508 failure_timings,
2509 }
2510}
2511
2512fn batch_source_assumptions(
2513 batch_resolution: Option<&(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2514) -> (bool, Option<Arc<RwLock<bool>>>) {
2515 batch_resolution.map_or((false, None), |(resolver, _)| {
2516 (
2517 resolver.assumes_sources_immutable(),
2518 resolver.prepared_invalidation(),
2519 )
2520 })
2521}
2522
2523fn build_wasm_canisters_cached_internal(
2524 spec: &WasmBuildSpec,
2525 progress: &mut ProgressReporter<'_>,
2526 mut batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2527) -> Result<WasmBuildOutcome, WasmBuildError> {
2528 let total_started = Instant::now();
2529 validate_spec(spec)?;
2530 let (assumes_sources_immutable, prepared_invalidation) =
2531 batch_source_assumptions(batch_resolution.as_ref());
2532 progress.emit(WasmBuildProgressEvent::Started);
2533 if spec.shared_incremental_maintenance_config.is_some() {
2534 let outcome = build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2535 spec,
2536 total_started,
2537 progress,
2538 batch_resolution.take(),
2539 )?;
2540 emit_finished_progress(&outcome, progress);
2541 return Ok(outcome);
2542 }
2543 let (cache_lock, first_lock_wait) =
2544 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2545 ensure_cache_directory_tag(&spec.target_dir)?;
2546
2547 let resolved = resolve_initial_inputs(spec, batch_resolution.take(), progress)?;
2548 let isolated_acquisition =
2549 SharedIncrementalAcquisitionContext::isolated(prepared_invalidation.clone());
2550 if let Some(outcome) = try_reuse_wasm_artifacts(
2551 spec,
2552 &resolved,
2553 first_lock_wait,
2554 &isolated_acquisition,
2555 total_started,
2556 progress,
2557 )? {
2558 emit_finished_progress(&outcome, progress);
2559 return Ok(outcome);
2560 }
2561 progress.emit(WasmBuildProgressEvent::CacheMiss {
2562 fingerprint: resolved.fingerprint,
2563 });
2564
2565 let outcome = match &spec.cache_mode {
2566 WasmBuildCacheMode::Isolated => {
2567 let cache_entry = cache_entry_directory(spec, resolved.fingerprint);
2568 build_wasm_cache_miss(
2569 spec,
2570 resolved,
2571 first_lock_wait,
2572 isolated_acquisition,
2573 cache_entry,
2574 total_started,
2575 progress,
2576 )
2577 }
2578 WasmBuildCacheMode::SharedIncremental { .. } => {
2579 drop(cache_lock);
2580 build_wasm_with_shared_incremental(
2581 spec,
2582 resolved,
2583 first_lock_wait,
2584 assumes_sources_immutable,
2585 prepared_invalidation,
2586 total_started,
2587 progress,
2588 )
2589 }
2590 }?;
2591 emit_finished_progress(&outcome, progress);
2592 Ok(outcome)
2593}
2594
2595fn build_wasm_with_shared_incremental(
2596 spec: &WasmBuildSpec,
2597 resolved: ResolvedCargoBuildInputs,
2598 first_lock_wait: Duration,
2599 assumes_sources_immutable: bool,
2600 prepared_invalidation: Option<Arc<RwLock<bool>>>,
2601 total_started: Instant,
2602 progress: &mut ProgressReporter<'_>,
2603) -> Result<WasmBuildOutcome, WasmBuildError> {
2604 let configured_target = shared_incremental_target(spec)
2605 .expect("shared cache mode must resolve a shared Cargo target");
2606 progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted {
2607 target_dir: configured_target,
2608 });
2609 let (shared_lock, shared_lock_wait, shared_target) =
2610 lock_shared_incremental_target_with_progress(spec, progress)?;
2611 progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
2612 target_dir: shared_target.clone(),
2613 wait: shared_lock_wait,
2614 });
2615 let (_cache_lock, second_lock_wait) =
2616 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2617 ensure_cache_directory_tag(&spec.target_dir)?;
2618
2619 let current = if assumes_sources_immutable {
2620 resolved
2621 } else {
2622 let mut current = resolve_inputs_with_progress(spec, progress)?;
2623 current.timings.include(resolved.timings);
2624 current
2625 };
2626 let lock_wait = first_lock_wait.saturating_add(second_lock_wait);
2627 let shared_incremental =
2628 SharedIncrementalAcquisitionContext::shared(shared_lock_wait, None, prepared_invalidation);
2629 if let Some(outcome) = try_reuse_wasm_artifacts(
2630 spec,
2631 ¤t,
2632 lock_wait,
2633 &shared_incremental,
2634 total_started,
2635 progress,
2636 )? {
2637 return Ok(outcome);
2638 }
2639
2640 let outcome = build_wasm_cache_miss(
2641 spec,
2642 current,
2643 lock_wait,
2644 shared_incremental,
2645 shared_target,
2646 total_started,
2647 progress,
2648 );
2649 drop(shared_lock);
2650 outcome
2651}
2652
2653fn build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2654 spec: &WasmBuildSpec,
2655 total_started: Instant,
2656 progress: &mut ProgressReporter<'_>,
2657 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2658) -> Result<WasmBuildOutcome, WasmBuildError> {
2659 let (_, prepared_invalidation) = batch_source_assumptions(batch_resolution.as_ref());
2660 let configured_target = shared_incremental_target(spec)
2661 .expect("validated scheduled maintenance must have a shared Cargo target");
2662 progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted {
2663 target_dir: configured_target,
2664 });
2665 let (_shared_lock, shared_lock_wait, shared_target) =
2666 lock_shared_incremental_target_with_progress(spec, progress)?;
2667 progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
2668 target_dir: shared_target.clone(),
2669 wait: shared_lock_wait,
2670 });
2671 let (_cache_lock, lock_wait) = lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2672 ensure_cache_directory_tag(&spec.target_dir)?;
2673
2674 let resolved = resolve_initial_inputs(spec, batch_resolution, progress)?;
2677 let shared_maintenance = perform_configured_shared_incremental_target_maintenance(
2678 spec,
2679 &shared_target,
2680 shared_lock_wait,
2681 progress,
2682 )?;
2683 let shared_incremental = SharedIncrementalAcquisitionContext::shared(
2684 shared_lock_wait,
2685 Some(shared_maintenance),
2686 prepared_invalidation,
2687 );
2688 if let Some(outcome) = try_reuse_wasm_artifacts(
2689 spec,
2690 &resolved,
2691 lock_wait,
2692 &shared_incremental,
2693 total_started,
2694 progress,
2695 )? {
2696 return Ok(outcome);
2697 }
2698 progress.emit(WasmBuildProgressEvent::CacheMiss {
2699 fingerprint: resolved.fingerprint,
2700 });
2701 build_wasm_cache_miss(
2702 spec,
2703 resolved,
2704 lock_wait,
2705 shared_incremental,
2706 shared_target,
2707 total_started,
2708 progress,
2709 )
2710}
2711
2712fn perform_configured_shared_incremental_target_maintenance(
2713 spec: &WasmBuildSpec,
2714 shared_target: &Path,
2715 lock_wait: Duration,
2716 progress: &mut ProgressReporter<'_>,
2717) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2718 let config = spec
2719 .shared_incremental_maintenance_config
2720 .expect("configured shared-target maintenance must have settings");
2721 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceStarted {
2722 target_dir: shared_target.to_owned(),
2723 });
2724 let result = progress.run_phase(WasmBuildProgressPhase::SharedTargetMaintenance, || {
2725 let schedule = schedule_shared_incremental_target_maintenance(
2726 shared_target,
2727 config.policy,
2728 config.minimum_interval,
2729 lock_wait,
2730 )?;
2731 match schedule {
2732 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => Ok(outcome),
2733 SharedIncrementalTargetMaintenanceSchedule::Due(due) => {
2734 perform_due_shared_incremental_target_maintenance(
2735 shared_target,
2736 config.policy,
2737 lock_wait,
2738 due,
2739 )
2740 }
2741 }
2742 });
2743 let outcome = integrated_shared_maintenance_result(config, shared_target, lock_wait, result)?;
2744 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceFinished {
2745 outcome: outcome.clone(),
2746 });
2747 Ok(outcome)
2748}
2749
2750fn integrated_shared_maintenance_result(
2751 config: SharedIncrementalTargetMaintenanceConfig,
2752 shared_target: &Path,
2753 lock_wait: Duration,
2754 result: Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError>,
2755) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2756 match result {
2757 Ok(outcome) => Ok(outcome),
2758 Err(error)
2759 if config.failure_mode == SharedIncrementalTargetMaintenanceFailureMode::BestEffort =>
2760 {
2761 Ok(SharedIncrementalTargetMaintenanceOutcome::Failed {
2762 target_dir: shared_target.to_owned(),
2763 lock_wait,
2764 message: error.to_string(),
2765 })
2766 }
2767 Err(error) => Err(error),
2768 }
2769}
2770
2771fn resolve_inputs_with_progress(
2772 spec: &WasmBuildSpec,
2773 progress: &mut ProgressReporter<'_>,
2774) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2775 let resolved = build_fingerprint_with_progress(spec, progress)?;
2776 progress.emit(WasmBuildProgressEvent::InputsResolved {
2777 fingerprint: resolved.fingerprint,
2778 input_digest: resolved.input_digest,
2779 elapsed: resolved.timings.total,
2780 });
2781 Ok(resolved)
2782}
2783
2784fn resolve_initial_inputs(
2785 spec: &WasmBuildSpec,
2786 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2787 progress: &mut ProgressReporter<'_>,
2788) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2789 let resolved = if let Some((resolver, index)) = batch_resolution {
2790 resolver.resolve(index, progress)?
2791 } else {
2792 build_fingerprint_with_progress(spec, progress)?
2793 };
2794 progress.emit(WasmBuildProgressEvent::InputsResolved {
2795 fingerprint: resolved.fingerprint,
2796 input_digest: resolved.input_digest,
2797 elapsed: resolved.timings.total,
2798 });
2799 Ok(resolved)
2800}
2801
2802fn emit_finished_progress(outcome: &WasmBuildOutcome, progress: &mut ProgressReporter<'_>) {
2803 let state = if outcome.is_reused() {
2804 progress.emit(WasmBuildProgressEvent::CacheHit {
2805 fingerprint: outcome.record().fingerprint,
2806 });
2807 WasmBuildProgressOutcome::Reused
2808 } else {
2809 WasmBuildProgressOutcome::Built
2810 };
2811 progress.emit(WasmBuildProgressEvent::Finished {
2812 outcome: state,
2813 fingerprint: outcome.record().fingerprint,
2814 elapsed: outcome.record().timings.total,
2815 });
2816}
2817
2818#[derive(Clone, Debug, Default)]
2819struct SharedIncrementalAcquisitionContext {
2820 lock_wait: Option<Duration>,
2821 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
2822 prepared_invalidation: Option<Arc<RwLock<bool>>>,
2823}
2824
2825impl SharedIncrementalAcquisitionContext {
2826 fn isolated(prepared_invalidation: Option<Arc<RwLock<bool>>>) -> Self {
2827 Self {
2828 prepared_invalidation,
2829 ..Self::default()
2830 }
2831 }
2832
2833 const fn shared(
2834 lock_wait: Duration,
2835 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
2836 prepared_invalidation: Option<Arc<RwLock<bool>>>,
2837 ) -> Self {
2838 Self {
2839 lock_wait: Some(lock_wait),
2840 maintenance,
2841 prepared_invalidation,
2842 }
2843 }
2844
2845 fn lock_prepared_publication(
2846 &self,
2847 ) -> Result<Option<std::sync::RwLockReadGuard<'_, bool>>, WasmBuildError> {
2848 let guard = self.prepared_invalidation.as_deref().map(|invalidation| {
2849 invalidation
2850 .read()
2851 .unwrap_or_else(std::sync::PoisonError::into_inner)
2852 });
2853 if guard.as_deref().is_some_and(|invalidated| *invalidated) {
2854 return Err(WasmBuildError::PreparedInputSnapshotInvalidated);
2855 }
2856 Ok(guard)
2857 }
2858}
2859
2860fn try_reuse_wasm_artifacts(
2861 spec: &WasmBuildSpec,
2862 resolved: &ResolvedCargoBuildInputs,
2863 lock_wait: Duration,
2864 shared_incremental: &SharedIncrementalAcquisitionContext,
2865 total_started: Instant,
2866 progress: &mut ProgressReporter<'_>,
2867) -> Result<Option<WasmBuildOutcome>, WasmBuildError> {
2868 let _publication_guard = shared_incremental.lock_prepared_publication()?;
2869 let fingerprint = resolved.fingerprint;
2870 let artifacts = expected_artifacts(spec, &spec.target_dir);
2871 let cache_entry = cache_entry_directory(spec, fingerprint);
2872 let artifacts_match = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2873 artifact_set_matches(&artifacts, fingerprint)
2874 });
2875 if artifacts_match {
2876 ensure_exact_cache_entry(spec, &artifacts, &cache_entry, fingerprint, progress)?;
2877 return Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
2878 spec,
2879 BuildRecordInput {
2880 fingerprint,
2881 input_digest: resolved.input_digest,
2882 lock_wait,
2883 shared_incremental: shared_incremental.clone(),
2884 input_resolution: resolved.timings,
2885 cargo_build: None,
2886 active_entry: &cache_entry,
2887 },
2888 total_started,
2889 progress,
2890 )?)));
2891 }
2892
2893 let cached_artifacts = expected_artifacts(spec, &cache_entry);
2894 let cached_artifacts_match = progress
2895 .run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2896 artifact_set_matches(&cached_artifacts, fingerprint)
2897 });
2898 if !cached_artifacts_match {
2899 return Ok(None);
2900 }
2901 progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2902 materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
2903 record_cache_entry_use(&cache_entry)
2904 })?;
2905 Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
2906 spec,
2907 BuildRecordInput {
2908 fingerprint,
2909 input_digest: resolved.input_digest,
2910 lock_wait,
2911 shared_incremental: shared_incremental.clone(),
2912 input_resolution: resolved.timings,
2913 cargo_build: None,
2914 active_entry: &cache_entry,
2915 },
2916 total_started,
2917 progress,
2918 )?)))
2919}
2920
2921fn ensure_exact_cache_entry(
2922 spec: &WasmBuildSpec,
2923 artifacts: &[PathBuf],
2924 cache_entry: &Path,
2925 fingerprint: InputDigest,
2926 progress: &mut ProgressReporter<'_>,
2927) -> Result<(), WasmBuildError> {
2928 let cached_artifacts = expected_artifacts(spec, cache_entry);
2929 let entry_is_current =
2930 progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2931 if artifact_set_matches(&cached_artifacts, fingerprint) {
2932 record_cache_entry_use(cache_entry)?;
2933 Ok::<_, WasmBuildError>(true)
2934 } else {
2935 Ok(false)
2936 }
2937 })?;
2938 if entry_is_current {
2939 return Ok(());
2940 }
2941 progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2942 remove_unretained_entry(cache_entry).map_err(wasm_cache_fs_error)?;
2943 create_dir_all(
2944 cache_entry,
2945 "create content-addressed Cargo target directory",
2946 )
2947 })?;
2948 let incomplete = IncompleteBuildDirectory::new(cache_entry.to_owned());
2949 let result = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
2950 copy_wasm_artifacts(artifacts, &cached_artifacts)?;
2951 publish_artifact_stamps(&cached_artifacts, fingerprint)?;
2952 record_cache_entry_use(cache_entry)
2953 });
2954 match result {
2955 Ok(()) => {
2956 incomplete.preserve();
2957 Ok(())
2958 }
2959 Err(build_error) => Err(cleanup_failed_fingerprint_build(
2960 build_error,
2961 incomplete,
2962 progress,
2963 )),
2964 }
2965}
2966
2967fn build_wasm_cache_miss(
2968 spec: &WasmBuildSpec,
2969 resolved: ResolvedCargoBuildInputs,
2970 lock_wait: Duration,
2971 shared_incremental: SharedIncrementalAcquisitionContext,
2972 cargo_target_dir: PathBuf,
2973 total_started: Instant,
2974 progress: &mut ProgressReporter<'_>,
2975) -> Result<WasmBuildOutcome, WasmBuildError> {
2976 let fingerprint = resolved.fingerprint;
2977 let mut input_resolution = resolved.timings;
2978 let artifacts = expected_artifacts(spec, &spec.target_dir);
2979 let cache_entry = cache_entry_directory(spec, fingerprint);
2980 let preparation_started = Instant::now();
2981 progress.begin_phase(WasmBuildFailurePhase::ArtifactPublication);
2982 let preparation_result = (|| {
2983 remove_unretained_entry(&cache_entry).map_err(wasm_cache_fs_error)?;
2984 create_dir_all(
2985 &cache_entry,
2986 "create content-addressed Cargo target directory",
2987 )
2988 })();
2989 progress.record_phase(
2990 WasmBuildFailurePhase::ArtifactPublication,
2991 preparation_started.elapsed(),
2992 );
2993 preparation_result?;
2994 let incomplete_directory = IncompleteBuildDirectory::new(cache_entry.clone());
2995 let build_result = (|| {
2996 if matches!(
2997 spec.cache_mode,
2998 WasmBuildCacheMode::SharedIncremental { .. }
2999 ) {
3000 record_cache_entry_use(&cargo_target_dir)?;
3001 }
3002 let build_started = Instant::now();
3003 progress.begin_phase(WasmBuildFailurePhase::CargoBuild);
3004 let cargo_result = run_cargo_build(spec, &cargo_target_dir, progress);
3005 let cargo_build = build_started.elapsed();
3006 progress.record_phase(WasmBuildFailurePhase::CargoBuild, cargo_build);
3007 cargo_result?;
3008 let built_artifacts = expected_artifacts(spec, &cargo_target_dir);
3009 let validation_started = Instant::now();
3010 progress.begin_phase(WasmBuildFailurePhase::ArtifactPublication);
3011 let missing = missing_artifacts(&built_artifacts);
3012 progress.record_phase(
3013 WasmBuildFailurePhase::ArtifactPublication,
3014 validation_started.elapsed(),
3015 );
3016 if !missing.is_empty() {
3017 return Err(WasmBuildError::MissingArtifacts { paths: missing });
3018 }
3019
3020 let verified = resolve_inputs_with_progress(spec, progress)?;
3021 input_resolution.include(verified.timings);
3022 if resolved.validation_digest != verified.validation_digest {
3023 return Err(WasmBuildError::InputsChangedDuringBuild {
3024 before: resolved.validation_digest,
3025 after: verified.validation_digest,
3026 });
3027 }
3028 if fingerprint != verified.fingerprint {
3029 return Err(WasmBuildError::InputsChangedDuringBuild {
3030 before: fingerprint,
3031 after: verified.fingerprint,
3032 });
3033 }
3034
3035 let publication_guard = shared_incremental.lock_prepared_publication()?;
3040
3041 let cached_artifacts = expected_artifacts(spec, &cache_entry);
3042 progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3043 if cargo_target_dir != cache_entry {
3044 copy_wasm_artifacts(&built_artifacts, &cached_artifacts)?;
3045 }
3046 publish_artifact_stamps(&cached_artifacts, fingerprint)?;
3047 materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
3048 record_cache_entry_use(&cache_entry)
3049 })?;
3050 drop(publication_guard);
3051
3052 Ok(WasmBuildOutcome::Built(complete_build_record(
3053 spec,
3054 BuildRecordInput {
3055 fingerprint,
3056 input_digest: resolved.input_digest,
3057 lock_wait,
3058 shared_incremental,
3059 input_resolution,
3060 cargo_build: Some(cargo_build),
3061 active_entry: &cache_entry,
3062 },
3063 total_started,
3064 progress,
3065 )?))
3066 })();
3067 finish_fingerprint_build(build_result, incomplete_directory, progress)
3068}
3069
3070pub fn prune_wasm_build_cache(
3079 target_dir: &Path,
3080 policy: ArtifactCachePrunePolicy,
3081) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3082 let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
3083 ensure_cache_directory_tag(target_dir)?;
3084
3085 prune_wasm_build_cache_locked(target_dir, policy, None)
3086}
3087
3088struct BuildRecordInput<'a> {
3089 fingerprint: InputDigest,
3090 input_digest: InputDigest,
3091 lock_wait: Duration,
3092 shared_incremental: SharedIncrementalAcquisitionContext,
3093 input_resolution: WasmInputResolutionTimings,
3094 cargo_build: Option<Duration>,
3095 active_entry: &'a Path,
3096}
3097
3098fn complete_build_record(
3099 spec: &WasmBuildSpec,
3100 input: BuildRecordInput<'_>,
3101 total_started: Instant,
3102 progress: &mut ProgressReporter<'_>,
3103) -> Result<WasmBuildRecord, WasmBuildError> {
3104 let retention = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3105 RetainedCacheEntry::acquire(input.active_entry).map_err(wasm_cache_fs_error)
3106 })?;
3107 let (maintenance, cache_maintenance) = spec.prune_policy.map_or((None, None), |policy| {
3108 progress.run_phase(WasmBuildProgressPhase::ExactCacheMaintenance, || {
3109 let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
3110 let identity = policy.maintenance_identity();
3111 perform_scheduled_cache_maintenance(&cache_root, spec.prune_interval, &identity, || {
3112 prune_wasm_build_cache_locked(&spec.target_dir, policy, Some(input.active_entry))
3113 .map_err(|error| error.to_string())
3114 })
3115 })
3116 });
3117 Ok(WasmBuildRecord {
3118 fingerprint: input.fingerprint,
3119 input_digest: input.input_digest,
3120 exact_cache_path: input.active_entry.to_owned(),
3121 artifacts: expected_artifacts(spec, input.active_entry),
3122 _retention: retention,
3123 timings: WasmBuildTimings {
3124 lock_wait: input.lock_wait,
3125 shared_incremental_lock_wait: input.shared_incremental.lock_wait,
3126 input_resolution: input.input_resolution,
3127 cargo_build: input.cargo_build,
3128 cache_maintenance,
3129 total: total_started.elapsed(),
3130 },
3131 maintenance,
3132 shared_incremental_maintenance: input.shared_incremental.maintenance,
3133 })
3134}
3135
3136fn prune_wasm_build_cache_locked(
3137 target_dir: &Path,
3138 policy: ArtifactCachePrunePolicy,
3139 protected_entry: Option<&Path>,
3140) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3141 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
3142 prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
3143 .map_err(wasm_cache_fs_error)
3144}
3145
3146struct IncompleteBuildDirectory {
3147 path: PathBuf,
3148 armed: bool,
3149}
3150
3151impl IncompleteBuildDirectory {
3152 const fn new(path: PathBuf) -> Self {
3153 Self { path, armed: true }
3154 }
3155
3156 fn preserve(mut self) {
3157 self.armed = false;
3158 }
3159
3160 fn cleanup(mut self) -> io::Result<()> {
3161 let result = remove_path_if_present(&self.path);
3162 if result.is_ok() {
3163 self.armed = false;
3164 }
3165 result
3166 }
3167}
3168
3169impl Drop for IncompleteBuildDirectory {
3170 fn drop(&mut self) {
3171 if self.armed {
3172 let _ = remove_path_if_present(&self.path);
3173 }
3174 }
3175}
3176
3177fn finish_fingerprint_build(
3178 result: Result<WasmBuildOutcome, WasmBuildError>,
3179 incomplete_directory: IncompleteBuildDirectory,
3180 progress: &mut ProgressReporter<'_>,
3181) -> Result<WasmBuildOutcome, WasmBuildError> {
3182 match result {
3183 Ok(outcome) => {
3184 incomplete_directory.preserve();
3185 Ok(outcome)
3186 }
3187 Err(build_error) => Err(cleanup_failed_fingerprint_build(
3188 build_error,
3189 incomplete_directory,
3190 progress,
3191 )),
3192 }
3193}
3194
3195fn cleanup_failed_fingerprint_build(
3196 build_error: WasmBuildError,
3197 incomplete_directory: IncompleteBuildDirectory,
3198 progress: &mut ProgressReporter<'_>,
3199) -> WasmBuildError {
3200 let path = incomplete_directory.path.clone();
3201 let primary_phase = progress.failure_phase;
3202 let cleanup_started = Instant::now();
3203 let cleanup = incomplete_directory.cleanup();
3204 progress.record_phase(WasmBuildFailurePhase::Cleanup, cleanup_started.elapsed());
3205 match cleanup {
3206 Ok(()) => {
3207 progress.failure_phase = primary_phase;
3208 build_error
3209 }
3210 Err(source) => WasmBuildError::FailedBuildCleanup {
3211 build_error: Box::new(build_error),
3212 path,
3213 source,
3214 },
3215 }
3216}
3217
3218fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
3219 create_dir_all(target_dir, "create Cargo target directory")?;
3220 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3221 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)
3222}
3223
3224fn lock_wasm_build_cache_with_progress(
3225 target_dir: &Path,
3226 progress: &mut ProgressReporter<'_>,
3227) -> Result<(File, Duration), WasmBuildError> {
3228 progress.begin_phase(WasmBuildFailurePhase::ExactCacheCoordination);
3229 create_dir_all(target_dir, "create Cargo target directory")?;
3230 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3231 lock_cache_file_with_progress(&lock_path, WasmBuildProgressPhase::ExactCacheLock, progress)
3232}
3233
3234fn lock_shared_incremental_target(
3235 spec: &WasmBuildSpec,
3236) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3237 lock_shared_incremental_target_internal(spec, None)
3238}
3239
3240fn lock_shared_incremental_target_with_progress(
3241 spec: &WasmBuildSpec,
3242 progress: &mut ProgressReporter<'_>,
3243) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3244 lock_shared_incremental_target_internal(spec, Some(progress))
3245}
3246
3247fn lock_shared_incremental_target_internal(
3248 spec: &WasmBuildSpec,
3249 mut progress: Option<&mut ProgressReporter<'_>>,
3250) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3251 if let Some(progress) = progress.as_deref_mut() {
3252 progress.begin_phase(WasmBuildFailurePhase::SharedTargetCoordination);
3253 }
3254 let target_dir =
3255 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
3256 message: "shared incremental target is not configured".to_owned(),
3257 })?;
3258 create_dir_all(
3259 &target_dir,
3260 "create shared incremental Cargo target directory",
3261 )?;
3262 ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
3263 let canonical = target_dir
3264 .canonicalize()
3265 .map_err(|source| WasmBuildError::Io {
3266 operation: "resolve shared incremental Cargo target directory",
3267 path: target_dir.clone(),
3268 source,
3269 })?;
3270 let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
3271 let (lock, wait) = if let Some(progress) = progress {
3272 lock_cache_file_with_progress(
3273 &lock_path,
3274 WasmBuildProgressPhase::SharedTargetLock,
3275 progress,
3276 )?
3277 } else {
3278 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)?
3279 };
3280 Ok((lock, wait, canonical))
3281}
3282
3283fn lock_cache_file_with_progress(
3284 lock_path: &Path,
3285 phase: WasmBuildProgressPhase,
3286 progress: &mut ProgressReporter<'_>,
3287) -> Result<(File, Duration), WasmBuildError> {
3288 let failure_phase = progress_failure_phase(phase);
3289 let started = Instant::now();
3290 progress.begin_phase(failure_phase);
3291 let result = if !progress.is_observed() || progress.config.heartbeat_interval.is_none() {
3292 lock_cache_file(lock_path).map_err(wasm_cache_fs_error)
3293 } else {
3294 let heartbeat_interval = progress
3295 .config
3296 .heartbeat_interval
3297 .expect("observed cache lock must have a heartbeat interval");
3298 lock_cache_file_with_wait_observer(lock_path, heartbeat_interval, |elapsed| {
3299 progress.emit_heartbeat_if_due(phase, elapsed);
3300 })
3301 .map_err(wasm_cache_fs_error)
3302 };
3303 progress.record_phase(failure_phase, started.elapsed());
3304 result
3305}
3306
3307fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
3308 ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
3309}
3310
3311fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
3312 record_entry_use(path).map_err(wasm_cache_fs_error)
3313}
3314
3315fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
3316 WasmBuildError::Io {
3317 operation: error.operation,
3318 path: error.path,
3319 source: error.source,
3320 }
3321}
3322
3323fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3324 if spec.packages.is_empty() {
3325 return Err(WasmBuildError::InvalidSpec {
3326 message: "at least one Cargo package is required".to_owned(),
3327 });
3328 }
3329 if spec.profile_target_dir.is_empty() {
3330 return Err(WasmBuildError::InvalidSpec {
3331 message: "Cargo profile target directory must not be empty".to_owned(),
3332 });
3333 }
3334 if spec.target.is_empty() {
3335 return Err(WasmBuildError::InvalidSpec {
3336 message: "Cargo compilation target must not be empty".to_owned(),
3337 });
3338 }
3339 if matches!(
3340 &spec.cache_mode,
3341 WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
3342 ) {
3343 return Err(WasmBuildError::InvalidSpec {
3344 message: "shared incremental Cargo target directory must not be empty".to_owned(),
3345 });
3346 }
3347 if spec.shared_incremental_maintenance_config.is_some()
3348 && !matches!(
3349 spec.cache_mode,
3350 WasmBuildCacheMode::SharedIncremental { .. }
3351 )
3352 {
3353 return Err(WasmBuildError::InvalidSpec {
3354 message:
3355 "scheduled shared-target maintenance requires a shared incremental Cargo target"
3356 .to_owned(),
3357 });
3358 }
3359 Ok(())
3360}
3361
3362fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3363 build_fingerprint_with_progress(spec, &mut ProgressReporter::silent())
3364}
3365
3366fn build_fingerprint_with_progress(
3367 spec: &WasmBuildSpec,
3368 progress: &mut ProgressReporter<'_>,
3369) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3370 let total_started = Instant::now();
3371 let tool_started = Instant::now();
3372 let cargo_identity = progress.run_phase(WasmBuildProgressPhase::CargoIdentity, || {
3373 command_identity(
3374 spec,
3375 WasmBuildPhase::CargoIdentity,
3376 &spec.cargo_program,
3377 &["--version", "--verbose"],
3378 )
3379 })?;
3380 let rustc_program = spec
3381 .extra_env
3382 .get(OsStr::new("RUSTC"))
3383 .unwrap_or(&spec.rustc_program);
3384 let rustc_identity = progress.run_phase(WasmBuildProgressPhase::RustcIdentity, || {
3385 command_identity(spec, WasmBuildPhase::RustcIdentity, rustc_program, &["-vV"])
3386 })?;
3387 let tool_identity = tool_started.elapsed();
3388
3389 let metadata_started = Instant::now();
3390 let metadata = progress.run_phase(WasmBuildProgressPhase::CargoMetadata, || {
3391 cargo_metadata(spec)
3392 })?;
3393 let cargo_metadata = metadata_started.elapsed();
3394
3395 let discovery_started = Instant::now();
3396 let (inputs, exclusions) =
3397 progress.run_phase(WasmBuildProgressPhase::InputDiscovery, || {
3398 let inputs = resolve_local_inputs(spec, &metadata)?;
3399 validate_shared_incremental_target_boundary(spec, &inputs.validation_inputs)?;
3400 let exclusions = source_exclusions(spec, &inputs.validation_inputs);
3401 Ok::<_, WasmBuildError>((inputs, exclusions))
3402 })?;
3403 let input_discovery = discovery_started.elapsed();
3404
3405 let hashing_started = Instant::now();
3406 let (input_digest, validation_digest) =
3407 progress.run_phase(WasmBuildProgressPhase::ContentHashing, || {
3408 let mut cache = LabeledPathDigestCache::default();
3409 digest_resolved_local_inputs(
3410 &inputs,
3411 &exclusions,
3412 &mut cache,
3413 &spec.workspace_root,
3414 "hash Wasm build inputs",
3415 "hash semantic Wasm build inputs",
3416 )
3417 })?;
3418 let content_hashing = hashing_started.elapsed();
3419
3420 let fingerprint =
3421 finish_build_fingerprint(spec, &cargo_identity, &rustc_identity, input_digest);
3422 Ok(ResolvedCargoBuildInputs {
3423 fingerprint,
3424 input_digest,
3425 validation_digest,
3426 inputs: inputs
3427 .validation_inputs
3428 .into_iter()
3429 .map(|(label, path)| CargoBuildInput { label, path })
3430 .collect(),
3431 exclusions,
3432 timings: WasmInputResolutionTimings {
3433 tool_identity,
3434 cargo_metadata,
3435 input_discovery,
3436 content_hashing,
3437 total: total_started.elapsed(),
3438 },
3439 })
3440}
3441
3442fn finish_build_fingerprint(
3443 spec: &WasmBuildSpec,
3444 cargo_identity: &[u8],
3445 rustc_identity: &[u8],
3446 input_digest: InputDigest,
3447) -> InputDigest {
3448 let mut hasher = InputHasher::new(CACHE_FORMAT_VERSION);
3449 let mut packages = spec.packages.clone();
3450 packages.sort();
3451 packages.dedup();
3452 for package in packages {
3453 hasher.field("package", package.as_bytes());
3454 }
3455 hasher.field("target", spec.target.as_bytes());
3456 hasher.field("profile-target-dir", spec.profile_target_dir.as_bytes());
3457 for argument in &spec.cargo_profile_args {
3458 hasher.field("cargo-argument", &os_bytes(argument));
3459 }
3460 for (key, value) in effective_environment(spec) {
3461 hasher.field("environment-key", &os_bytes(&key));
3462 if let Some(value) = value {
3463 hasher.field("environment-value", &os_bytes(&value));
3464 } else {
3465 hasher.field("environment-unset", b"");
3466 }
3467 }
3468 hasher.field("cargo-identity", cargo_identity);
3469 hasher.field("rustc-identity", rustc_identity);
3470 hasher.field("source-input-digest", input_digest.as_bytes());
3471 hasher.finish()
3472}
3473
3474fn command_identity(
3475 spec: &WasmBuildSpec,
3476 phase: WasmBuildPhase,
3477 program: &OsStr,
3478 arguments: &[&str],
3479) -> Result<Vec<u8>, WasmBuildError> {
3480 let mut command = Command::new(program);
3481 command.current_dir(&spec.workspace_root).args(arguments);
3482 apply_command_environment(&mut command, spec);
3483 let output = command
3484 .output()
3485 .map_err(|source| WasmBuildError::CommandSpawn {
3486 phase,
3487 program: program.to_owned(),
3488 source,
3489 })?;
3490 ensure_command_success(phase, output).map(|output| {
3491 let mut identity = output.stdout;
3492 identity.extend_from_slice(&output.stderr);
3493 identity
3494 })
3495}
3496
3497fn cargo_metadata(spec: &WasmBuildSpec) -> Result<Value, WasmBuildError> {
3498 let mut command = Command::new(&spec.cargo_program);
3499 command
3500 .current_dir(&spec.workspace_root)
3501 .args(["metadata", "--format-version", "1"]);
3502 for argument in metadata_arguments(&spec.cargo_profile_args) {
3503 command.arg(argument);
3504 }
3505 apply_command_environment(&mut command, spec);
3506 let output = command
3507 .output()
3508 .map_err(|source| WasmBuildError::CommandSpawn {
3509 phase: WasmBuildPhase::CargoMetadata,
3510 program: spec.cargo_program.clone(),
3511 source,
3512 })?;
3513 let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
3514 serde_json::from_slice(&output.stdout).map_err(|error| WasmBuildError::InvalidMetadata {
3515 message: format!("Cargo metadata was not valid JSON: {error}"),
3516 })
3517}
3518
3519fn metadata_arguments(arguments: &[OsString]) -> Vec<OsString> {
3520 let mut selected = Vec::new();
3521 let mut arguments = arguments.iter();
3522 while let Some(argument) = arguments.next() {
3523 let argument_text = argument.to_string_lossy();
3524 match argument_text.as_ref() {
3525 "--all-features" | "--no-default-features" | "--locked" | "--offline" | "--frozen" => {
3526 selected.push(argument.clone());
3527 }
3528 "--features" | "-F" | "--filter-platform" => {
3529 selected.push(argument.clone());
3530 if let Some(value) = arguments.next() {
3531 selected.push(value.clone());
3532 }
3533 }
3534 _ if argument_text.starts_with("--features=")
3535 || argument_text.starts_with("--filter-platform=") =>
3536 {
3537 selected.push(argument.clone());
3538 }
3539 _ => {}
3540 }
3541 }
3542 selected
3543}
3544
3545#[derive(Clone)]
3546struct MetadataPackage {
3547 id: String,
3548 name: String,
3549 version: String,
3550 manifest_path: PathBuf,
3551 is_local: bool,
3552 source: Option<String>,
3553 semantic_fields: Vec<(&'static str, Option<String>)>,
3554}
3555
3556const SEMANTIC_PACKAGE_FIELDS: &[&str] = &[
3557 "authors",
3558 "default_run",
3559 "description",
3560 "documentation",
3561 "edition",
3562 "homepage",
3563 "license",
3564 "license_file",
3565 "links",
3566 "metadata",
3567 "name",
3568 "readme",
3569 "repository",
3570 "rust_version",
3571 "version",
3572];
3573
3574struct LockedPackageIdentity {
3575 name: String,
3576 version: String,
3577 source: String,
3578 checksum: Option<String>,
3579}
3580
3581fn resolve_local_inputs(
3582 spec: &WasmBuildSpec,
3583 metadata: &Value,
3584) -> Result<ResolvedLocalInputs, WasmBuildError> {
3585 let packages = metadata_packages(metadata)?;
3586 let mut selected_ids = selected_package_ids(spec, metadata, &packages)?;
3587 let dependencies = metadata_dependencies(metadata)?;
3588 let mut closure = BTreeSet::new();
3589 while let Some(id) = selected_ids.pop_front() {
3590 if !closure.insert(id.clone()) {
3591 continue;
3592 }
3593 if let Some(deps) = dependencies.get(&id) {
3594 selected_ids.extend(deps.iter().cloned());
3595 }
3596 }
3597
3598 let workspace_root = metadata
3599 .get("workspace_root")
3600 .and_then(Value::as_str)
3601 .map_or_else(|| spec.workspace_root.clone(), PathBuf::from);
3602 let projection = semantic_workspace_projection(metadata, &packages, &closure, &workspace_root)?;
3603 let mut validation_inputs = workspace_configuration_inputs(spec, &workspace_root)?;
3604 append_package_inputs(&mut validation_inputs, &packages, closure, &workspace_root)?;
3605 append_additional_inputs(&mut validation_inputs, spec, &workspace_root);
3606 let fingerprint = projection.map_or(LocalInputFingerprint::Conservative, |workspace| {
3607 LocalInputFingerprint::Projected {
3608 inputs: validation_inputs
3609 .iter()
3610 .filter(|(label, _)| !is_broad_workspace_input(label))
3611 .cloned()
3612 .collect(),
3613 workspace,
3614 }
3615 });
3616 Ok(ResolvedLocalInputs {
3617 validation_inputs,
3618 fingerprint,
3619 })
3620}
3621
3622fn metadata_packages(metadata: &Value) -> Result<HashMap<String, MetadataPackage>, WasmBuildError> {
3623 let packages_value = metadata
3624 .get("packages")
3625 .and_then(Value::as_array)
3626 .ok_or_else(|| invalid_metadata("Cargo metadata has no package array"))?;
3627 let mut packages = HashMap::new();
3628 for value in packages_value {
3629 let source = optional_string(value, "source")?;
3630 let package = MetadataPackage {
3631 id: required_string(value, "id")?,
3632 name: required_string(value, "name")?,
3633 version: required_string(value, "version")?,
3634 manifest_path: PathBuf::from(required_string(value, "manifest_path")?),
3635 is_local: value.get("source").is_some_and(Value::is_null),
3636 source,
3637 semantic_fields: SEMANTIC_PACKAGE_FIELDS
3638 .iter()
3639 .map(|field| (*field, value.get(*field).map(Value::to_string)))
3640 .collect(),
3641 };
3642 packages.insert(package.id.clone(), package);
3643 }
3644 Ok(packages)
3645}
3646
3647fn selected_package_ids(
3648 spec: &WasmBuildSpec,
3649 metadata: &Value,
3650 packages: &HashMap<String, MetadataPackage>,
3651) -> Result<VecDeque<String>, WasmBuildError> {
3652 let workspace_members = metadata
3653 .get("workspace_members")
3654 .and_then(Value::as_array)
3655 .ok_or_else(|| invalid_metadata("Cargo metadata has no workspace member array"))?
3656 .iter()
3657 .filter_map(Value::as_str)
3658 .collect::<HashSet<_>>();
3659 let mut selected_ids = VecDeque::new();
3660 for requested in &spec.packages {
3661 let matches = packages
3662 .values()
3663 .filter(|package| {
3664 package.name == *requested && workspace_members.contains(package.id.as_str())
3665 })
3666 .map(|package| package.id.clone())
3667 .collect::<Vec<_>>();
3668 match matches.as_slice() {
3669 [id] => selected_ids.push_back(id.clone()),
3670 [] => {
3671 return Err(WasmBuildError::InvalidSpec {
3672 message: format!("Cargo workspace contains no package named `{requested}`"),
3673 });
3674 }
3675 _ => {
3676 return Err(WasmBuildError::InvalidSpec {
3677 message: format!("Cargo workspace package name `{requested}` is ambiguous"),
3678 });
3679 }
3680 }
3681 }
3682 Ok(selected_ids)
3683}
3684
3685fn metadata_dependencies(metadata: &Value) -> Result<HashMap<String, Vec<String>>, WasmBuildError> {
3686 let mut dependencies = HashMap::<String, Vec<String>>::new();
3687 let nodes = metadata
3688 .pointer("/resolve/nodes")
3689 .and_then(Value::as_array)
3690 .ok_or_else(|| invalid_metadata("Cargo metadata has no resolved dependency nodes"))?;
3691 for node in nodes {
3692 let id = required_string(node, "id")?;
3693 let deps = node
3694 .get("deps")
3695 .and_then(Value::as_array)
3696 .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
3697 .iter()
3698 .map(|dependency| required_string(dependency, "pkg"))
3699 .collect::<Result<Vec<_>, _>>()?;
3700 dependencies.insert(id, deps);
3701 }
3702 Ok(dependencies)
3703}
3704
3705fn semantic_workspace_projection(
3706 metadata: &Value,
3707 packages: &HashMap<String, MetadataPackage>,
3708 closure: &BTreeSet<String>,
3709 workspace_root: &Path,
3710) -> Result<Option<InputDigest>, WasmBuildError> {
3711 let locked_packages = locked_package_identities(workspace_root)?;
3714 let mut identities = HashMap::new();
3715 for id in closure {
3716 let package = packages
3717 .get(id)
3718 .ok_or_else(|| invalid_metadata(&format!("resolved package `{id}` is missing")))?;
3719 let Some(identity) = semantic_package_identity(package, workspace_root, &locked_packages)
3720 else {
3721 return Ok(None);
3722 };
3723 identities.insert(id.as_str(), identity);
3724 }
3725
3726 let nodes = metadata
3727 .pointer("/resolve/nodes")
3728 .and_then(Value::as_array)
3729 .ok_or_else(|| invalid_metadata("Cargo metadata has no resolved dependency nodes"))?;
3730 let nodes_by_id = nodes
3731 .iter()
3732 .map(|node| Ok((required_string(node, "id")?, node)))
3733 .collect::<Result<HashMap<_, _>, WasmBuildError>>()?;
3734 let mut projected_packages = closure
3735 .iter()
3736 .map(|id| {
3737 let package = packages
3738 .get(id)
3739 .expect("selected package closure was validated above");
3740 let identity = identities[id.as_str()];
3741 let node = nodes_by_id.get(id).copied().ok_or_else(|| {
3742 invalid_metadata(&format!("resolved package `{id}` has no dependency node"))
3743 })?;
3744 let projection = semantic_package_projection(package, node, &identities)?;
3745 Ok::<_, WasmBuildError>((identity, projection))
3746 })
3747 .collect::<Result<Vec<_>, _>>()?;
3748 projected_packages.sort_by_key(|(identity, _)| *identity);
3749
3750 let root_manifest = workspace_root.join("Cargo.toml");
3751 let root_contents =
3752 fs::read_to_string(&root_manifest).map_err(|source| WasmBuildError::Io {
3753 operation: "read workspace manifest for semantic projection",
3754 path: root_manifest.clone(),
3755 source,
3756 })?;
3757 let root = toml::from_str::<TomlValue>(&root_contents).map_err(|error| {
3758 invalid_metadata(&format!(
3759 "workspace manifest could not be projected as TOML: {error}"
3760 ))
3761 })?;
3762
3763 let mut hasher = InputHasher::new("wasm-semantic-workspace-projection-v1");
3764 for (identity, projection) in projected_packages {
3765 hasher.field("package-identity", identity.as_bytes());
3766 hasher.field("package-projection", projection.as_bytes());
3767 }
3768 hash_toml_setting(&mut hasher, "cargo-features", root.get("cargo-features"));
3769 hash_toml_setting(&mut hasher, "profile", root.get("profile"));
3770 let workspace = root.get("workspace").and_then(TomlValue::as_table);
3771 hash_toml_setting(
3772 &mut hasher,
3773 "workspace-resolver",
3774 workspace.and_then(|table| table.get("resolver")),
3775 );
3776 hash_toml_setting(
3777 &mut hasher,
3778 "workspace-lints",
3779 workspace.and_then(|table| table.get("lints")),
3780 );
3781 Ok(Some(hasher.finish()))
3782}
3783
3784fn locked_package_identities(
3785 workspace_root: &Path,
3786) -> Result<Vec<LockedPackageIdentity>, WasmBuildError> {
3787 let lockfile = workspace_root.join("Cargo.lock");
3788 let contents = match fs::read_to_string(&lockfile) {
3789 Ok(contents) => contents,
3790 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
3791 Err(source) => {
3792 return Err(WasmBuildError::Io {
3793 operation: "read Cargo lockfile for semantic projection",
3794 path: lockfile,
3795 source,
3796 });
3797 }
3798 };
3799 let lock = toml::from_str::<TomlValue>(&contents).map_err(|error| {
3800 invalid_metadata(&format!(
3801 "Cargo lockfile could not be projected as TOML: {error}"
3802 ))
3803 })?;
3804 let Some(packages) = lock.get("package").and_then(TomlValue::as_array) else {
3805 return Ok(Vec::new());
3806 };
3807 packages
3808 .iter()
3809 .filter_map(|package| {
3810 let Some(table) = package.as_table() else {
3811 return Some(Err(invalid_metadata(
3812 "Cargo lockfile package entry is not a table",
3813 )));
3814 };
3815 let source = table.get("source")?.as_str().map(str::to_owned);
3816 Some(
3817 source
3818 .ok_or_else(|| {
3819 invalid_metadata("Cargo lockfile package source is not a string")
3820 })
3821 .and_then(|source| {
3822 Ok(LockedPackageIdentity {
3823 name: required_toml_string(table, "name", "Cargo lockfile package")?,
3824 version: required_toml_string(
3825 table,
3826 "version",
3827 "Cargo lockfile package",
3828 )?,
3829 source,
3830 checksum: optional_toml_string(
3831 table,
3832 "checksum",
3833 "Cargo lockfile package",
3834 )?,
3835 })
3836 }),
3837 )
3838 })
3839 .collect()
3840}
3841
3842fn required_toml_string(
3843 table: &toml::Table,
3844 field: &str,
3845 context: &str,
3846) -> Result<String, WasmBuildError> {
3847 table
3848 .get(field)
3849 .and_then(TomlValue::as_str)
3850 .map(str::to_owned)
3851 .ok_or_else(|| invalid_metadata(&format!("{context} `{field}` is missing or not a string")))
3852}
3853
3854fn optional_toml_string(
3855 table: &toml::Table,
3856 field: &str,
3857 context: &str,
3858) -> Result<Option<String>, WasmBuildError> {
3859 match table.get(field) {
3860 None => Ok(None),
3861 Some(TomlValue::String(value)) => Ok(Some(value.clone())),
3862 Some(_) => Err(invalid_metadata(&format!(
3863 "{context} `{field}` is not a string"
3864 ))),
3865 }
3866}
3867
3868fn semantic_package_identity(
3869 package: &MetadataPackage,
3870 workspace_root: &Path,
3871 locked_packages: &[LockedPackageIdentity],
3872) -> Option<InputDigest> {
3873 let mut hasher = InputHasher::new("wasm-semantic-package-identity-v1");
3874 hasher.field("name", package.name.as_bytes());
3875 hasher.field("version", package.version.as_bytes());
3876 if package.is_local {
3877 let manifest = package.manifest_path.strip_prefix(workspace_root).ok()?;
3878 let package_root = package.manifest_path.parent()?;
3879 if package_root == workspace_root {
3880 return None;
3881 }
3882 hasher.field("local-manifest", &os_bytes(manifest.as_os_str()));
3883 } else {
3884 let metadata_source = package.source.as_deref()?;
3885 let locked = locked_packages.iter().find(|locked| {
3886 locked.name == package.name
3887 && locked.version == package.version
3888 && locked.source == metadata_source
3889 })?;
3890 match locked.source.as_str() {
3891 source if source.starts_with("registry+") && locked.checksum.is_some() => {}
3892 source if source.starts_with("git+") && source.contains('#') => {}
3893 _ => return None,
3894 }
3895 hasher.field("external-package-id", package.id.as_bytes());
3896 hasher.field("external-source", locked.source.as_bytes());
3897 hasher.field(
3898 "external-checksum",
3899 locked.checksum.as_deref().unwrap_or_default().as_bytes(),
3900 );
3901 }
3902 Some(hasher.finish())
3903}
3904
3905fn semantic_package_projection(
3906 package: &MetadataPackage,
3907 node: &Value,
3908 identities: &HashMap<&str, InputDigest>,
3909) -> Result<InputDigest, WasmBuildError> {
3910 let mut hasher = InputHasher::new("wasm-semantic-package-projection-v1");
3914 for (field, value) in &package.semantic_fields {
3915 hasher.field("package-field-name", field.as_bytes());
3916 match value {
3917 Some(value) => hasher.field("package-field-value", value.as_bytes()),
3918 None => hasher.field("package-field-missing", b""),
3919 }
3920 }
3921
3922 let mut features = node
3923 .get("features")
3924 .and_then(Value::as_array)
3925 .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no features array"))?
3926 .iter()
3927 .map(|feature| {
3928 feature.as_str().map(str::to_owned).ok_or_else(|| {
3929 invalid_metadata("Cargo metadata dependency feature is not a string")
3930 })
3931 })
3932 .collect::<Result<Vec<_>, _>>()?;
3933 features.sort();
3934 for feature in features {
3935 hasher.field("enabled-feature", feature.as_bytes());
3936 }
3937
3938 let mut dependencies = node
3939 .get("deps")
3940 .and_then(Value::as_array)
3941 .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
3942 .iter()
3943 .map(|dependency| {
3944 let name = required_string(dependency, "name")?;
3945 let package_id = required_string(dependency, "pkg")?;
3946 let identity = identities
3947 .get(package_id.as_str())
3948 .copied()
3949 .ok_or_else(|| {
3950 invalid_metadata(&format!(
3951 "dependency `{package_id}` is outside the selected package closure"
3952 ))
3953 })?;
3954 let kinds = dependency
3955 .get("dep_kinds")
3956 .ok_or_else(|| invalid_metadata("Cargo metadata dependency has no kind array"))?
3957 .to_string();
3958 Ok::<_, WasmBuildError>((name, identity, kinds))
3959 })
3960 .collect::<Result<Vec<_>, _>>()?;
3961 dependencies.sort();
3962 for (name, identity, kinds) in dependencies {
3963 hasher.field("dependency-name", name.as_bytes());
3964 hasher.field("dependency-identity", identity.as_bytes());
3965 hasher.field("dependency-kinds", kinds.as_bytes());
3966 }
3967 Ok(hasher.finish())
3968}
3969
3970fn hash_toml_setting(hasher: &mut InputHasher, label: &str, value: Option<&TomlValue>) {
3971 hasher.field("workspace-setting-name", label.as_bytes());
3972 match value {
3973 Some(value) => hasher.field("workspace-setting-value", value.to_string().as_bytes()),
3974 None => hasher.field("workspace-setting-missing", b""),
3975 }
3976}
3977
3978fn is_broad_workspace_input(label: &Path) -> bool {
3979 label == Path::new("workspace/Cargo.toml") || label == Path::new("workspace/Cargo.lock")
3980}
3981
3982fn digest_resolved_local_inputs(
3983 inputs: &ResolvedLocalInputs,
3984 exclusions: &[PathBuf],
3985 cache: &mut LabeledPathDigestCache,
3986 error_path: &Path,
3987 validation_operation: &'static str,
3988 semantic_operation: &'static str,
3989) -> Result<(InputDigest, InputDigest), WasmBuildError> {
3990 let validation_digest = digest_labeled_paths_composable(
3991 "wasm-source-inputs-v1",
3992 &inputs.validation_inputs,
3993 exclusions,
3994 cache,
3995 )
3996 .map_err(|source| WasmBuildError::Io {
3997 operation: validation_operation,
3998 path: error_path.to_owned(),
3999 source,
4000 })?;
4001 let input_digest = semantic_input_digest(inputs, validation_digest, exclusions, cache)
4002 .map_err(|source| WasmBuildError::Io {
4003 operation: semantic_operation,
4004 path: error_path.to_owned(),
4005 source,
4006 })?;
4007 Ok((input_digest, validation_digest))
4008}
4009
4010fn semantic_input_digest(
4011 inputs: &ResolvedLocalInputs,
4012 validation_digest: InputDigest,
4013 exclusions: &[PathBuf],
4014 cache: &mut LabeledPathDigestCache,
4015) -> io::Result<InputDigest> {
4016 let LocalInputFingerprint::Projected {
4017 inputs: fingerprint_inputs,
4018 workspace,
4019 } = &inputs.fingerprint
4020 else {
4021 return Ok(validation_digest);
4022 };
4023 let path_digest = digest_labeled_paths_composable(
4024 "wasm-source-inputs-v1",
4025 fingerprint_inputs,
4026 exclusions,
4027 cache,
4028 )?;
4029 let mut hasher = InputHasher::new("wasm-semantic-source-inputs-v1");
4030 hasher.field("path-input-digest", path_digest.as_bytes());
4031 hasher.field("workspace-projection", workspace.as_bytes());
4032 Ok(hasher.finish())
4033}
4034
4035fn workspace_configuration_inputs(
4036 spec: &WasmBuildSpec,
4037 workspace_root: &Path,
4038) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
4039 let mut inputs = Vec::new();
4040 add_if_present(
4041 &mut inputs,
4042 "workspace/Cargo.toml",
4043 workspace_root.join("Cargo.toml"),
4044 );
4045 add_if_present(
4046 &mut inputs,
4047 "workspace/Cargo.lock",
4048 workspace_root.join("Cargo.lock"),
4049 );
4050 add_if_present(
4051 &mut inputs,
4052 "workspace/rust-toolchain.toml",
4053 workspace_root.join("rust-toolchain.toml"),
4054 );
4055 add_if_present(
4056 &mut inputs,
4057 "workspace/rust-toolchain",
4058 workspace_root.join("rust-toolchain"),
4059 );
4060 append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
4061 Ok(inputs)
4062}
4063
4064fn append_cargo_configuration_inputs(
4065 inputs: &mut Vec<(PathBuf, PathBuf)>,
4066 spec: &WasmBuildSpec,
4067 workspace_root: &Path,
4068) -> Result<(), WasmBuildError> {
4069 let invocation_root =
4070 spec.workspace_root
4071 .canonicalize()
4072 .map_err(|source| WasmBuildError::Io {
4073 operation: "resolve Cargo invocation directory",
4074 path: spec.workspace_root.clone(),
4075 source,
4076 })?;
4077 let canonical_workspace =
4078 workspace_root
4079 .canonicalize()
4080 .map_err(|source| WasmBuildError::Io {
4081 operation: "resolve Cargo workspace directory",
4082 path: workspace_root.to_owned(),
4083 source,
4084 })?;
4085
4086 let mut roots = invocation_root
4087 .ancestors()
4088 .filter_map(|directory| effective_cargo_config(&directory.join(".cargo")))
4089 .collect::<Vec<_>>();
4090 if let Some(cargo_home) = effective_cargo_home(spec, &invocation_root)
4091 && let Some(config) = effective_cargo_config(&cargo_home)
4092 {
4093 roots.push(config);
4094 }
4095
4096 let mut visited = BTreeSet::new();
4097 for config in roots {
4098 append_cargo_configuration_tree(
4099 inputs,
4100 &config,
4101 &canonical_workspace,
4102 &mut visited,
4103 false,
4104 )?;
4105 }
4106 Ok(())
4107}
4108
4109fn effective_cargo_config(directory: &Path) -> Option<PathBuf> {
4110 let extensionless = directory.join("config");
4111 if extensionless.exists() {
4112 return Some(extensionless);
4113 }
4114 let toml = directory.join("config.toml");
4115 toml.exists().then_some(toml)
4116}
4117
4118fn effective_cargo_home(spec: &WasmBuildSpec, invocation_root: &Path) -> Option<PathBuf> {
4119 if let Some(cargo_home) = command_environment_value(spec, "CARGO_HOME") {
4120 let cargo_home = PathBuf::from(cargo_home);
4121 return Some(if cargo_home.is_absolute() {
4122 cargo_home
4123 } else {
4124 invocation_root.join(cargo_home)
4125 });
4126 }
4127
4128 default_home_directory(spec).map(|home| {
4129 let home = if home.is_absolute() {
4130 home
4131 } else {
4132 invocation_root.join(home)
4133 };
4134 home.join(".cargo")
4135 })
4136}
4137
4138#[cfg(windows)]
4139fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4140 command_environment_value(spec, "USERPROFILE")
4141 .or_else(|| command_environment_value(spec, "HOME"))
4142 .map(PathBuf::from)
4143}
4144
4145#[cfg(not(windows))]
4146fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4147 command_environment_value(spec, "HOME").map(PathBuf::from)
4148}
4149
4150fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
4151 spec.extra_env
4152 .get(OsStr::new(name))
4153 .cloned()
4154 .or_else(|| std::env::var_os(name))
4155}
4156
4157fn append_cargo_configuration_tree(
4158 inputs: &mut Vec<(PathBuf, PathBuf)>,
4159 config: &Path,
4160 workspace_root: &Path,
4161 visited: &mut BTreeSet<PathBuf>,
4162 optional: bool,
4163) -> Result<(), WasmBuildError> {
4164 let canonical = match config.canonicalize() {
4165 Ok(canonical) => canonical,
4166 Err(error) if optional && error.kind() == io::ErrorKind::NotFound => return Ok(()),
4167 Err(source) => {
4168 return Err(WasmBuildError::Io {
4169 operation: "resolve Cargo configuration",
4170 path: config.to_owned(),
4171 source,
4172 });
4173 }
4174 };
4175 if !visited.insert(canonical.clone()) {
4176 return Ok(());
4177 }
4178
4179 let contents = fs::read_to_string(&canonical).map_err(|source| WasmBuildError::Io {
4180 operation: "read Cargo configuration",
4181 path: canonical.clone(),
4182 source,
4183 })?;
4184 let configuration = toml::from_str::<TomlValue>(&contents).map_err(|error| {
4185 WasmBuildError::InvalidCargoConfiguration {
4186 path: canonical.clone(),
4187 message: error.to_string(),
4188 }
4189 })?;
4190 inputs.push((
4191 cargo_configuration_label(&canonical, workspace_root),
4192 canonical.clone(),
4193 ));
4194
4195 let Some(include) = configuration.get("include") else {
4196 return Ok(());
4197 };
4198 let parent = canonical
4199 .parent()
4200 .ok_or_else(|| WasmBuildError::InvalidCargoConfiguration {
4201 path: canonical.clone(),
4202 message: "configuration path has no parent directory".to_owned(),
4203 })?;
4204 for (included, optional) in cargo_configuration_includes(include, &canonical)? {
4205 let included = if included.is_absolute() {
4206 included
4207 } else {
4208 parent.join(included)
4209 };
4210 append_cargo_configuration_tree(inputs, &included, workspace_root, visited, optional)?;
4211 }
4212 Ok(())
4213}
4214
4215fn cargo_configuration_includes(
4216 include: &TomlValue,
4217 config: &Path,
4218) -> Result<Vec<(PathBuf, bool)>, WasmBuildError> {
4219 let values = match include {
4220 TomlValue::Array(values) => values.as_slice(),
4221 value => std::slice::from_ref(value),
4222 };
4223 values
4224 .iter()
4225 .map(|value| match value {
4226 TomlValue::String(path) => Ok((PathBuf::from(path), false)),
4227 TomlValue::Table(table) => {
4228 let path = table
4229 .get("path")
4230 .and_then(TomlValue::as_str)
4231 .ok_or_else(|| {
4232 invalid_cargo_configuration(
4233 config,
4234 "Cargo configuration include table requires a string `path`",
4235 )
4236 })?;
4237 let optional = table
4238 .get("optional")
4239 .map(|value| {
4240 value.as_bool().ok_or_else(|| {
4241 invalid_cargo_configuration(
4242 config,
4243 "Cargo configuration include `optional` must be a boolean",
4244 )
4245 })
4246 })
4247 .transpose()?
4248 .unwrap_or(false);
4249 Ok((PathBuf::from(path), optional))
4250 }
4251 _ => Err(invalid_cargo_configuration(
4252 config,
4253 "Cargo configuration `include` must contain paths or include tables",
4254 )),
4255 })
4256 .collect()
4257}
4258
4259fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
4260 if let Ok(relative) = config.strip_prefix(workspace_root) {
4261 return PathBuf::from("cargo-config/workspace").join(relative);
4262 }
4263 let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
4264 PathBuf::from("cargo-config/external").join(location.to_hex())
4265}
4266
4267fn invalid_cargo_configuration(path: &Path, message: &str) -> WasmBuildError {
4268 WasmBuildError::InvalidCargoConfiguration {
4269 path: path.to_owned(),
4270 message: message.to_owned(),
4271 }
4272}
4273
4274fn append_package_inputs(
4275 inputs: &mut Vec<(PathBuf, PathBuf)>,
4276 packages: &HashMap<String, MetadataPackage>,
4277 closure: BTreeSet<String>,
4278 workspace_root: &Path,
4279) -> Result<(), WasmBuildError> {
4280 for id in closure {
4281 let Some(package) = packages.get(&id) else {
4282 return Err(invalid_metadata(&format!(
4283 "resolved package `{id}` is missing"
4284 )));
4285 };
4286 if !package.is_local {
4287 continue;
4288 }
4289 let root = package.manifest_path.parent().ok_or_else(|| {
4290 invalid_metadata(&format!(
4291 "package `{}` manifest has no parent",
4292 package.name
4293 ))
4294 })?;
4295 let relative_manifest = package
4296 .manifest_path
4297 .strip_prefix(workspace_root)
4298 .unwrap_or(&package.manifest_path);
4299 let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
4300 .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
4301 inputs.push((label, root.to_owned()));
4302 }
4303 Ok(())
4304}
4305
4306fn append_additional_inputs(
4307 inputs: &mut Vec<(PathBuf, PathBuf)>,
4308 spec: &WasmBuildSpec,
4309 workspace_root: &Path,
4310) {
4311 for additional in &spec.additional_inputs {
4312 let path = if additional.is_absolute() {
4313 additional.clone()
4314 } else {
4315 workspace_root.join(additional)
4316 };
4317 inputs.push((PathBuf::from("additional").join(additional), path));
4318 }
4319}
4320
4321fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
4322 let mut exclusions = vec![
4323 spec.target_dir.clone(),
4324 spec.workspace_root.join("target"),
4325 spec.workspace_root.join(".git"),
4326 ];
4327 if let Some(shared_target) = shared_incremental_target(spec) {
4328 exclusions.push(shared_target);
4329 }
4330 for (_, path) in inputs {
4331 if path.is_dir() {
4332 exclusions.push(path.join("target"));
4333 exclusions.push(path.join(".git"));
4334 }
4335 }
4336 exclusions
4337}
4338
4339fn validate_shared_incremental_target_boundary(
4340 spec: &WasmBuildSpec,
4341 inputs: &[(PathBuf, PathBuf)],
4342) -> Result<(), WasmBuildError> {
4343 let Some(shared_target) = shared_incremental_target(spec) else {
4344 return Ok(());
4345 };
4346 let shared_target =
4347 canonicalize_allow_missing(&shared_target).map_err(|source| WasmBuildError::Io {
4348 operation: "resolve shared incremental Cargo target boundary",
4349 path: shared_target.clone(),
4350 source,
4351 })?;
4352 let resolved_inputs = inputs
4353 .iter()
4354 .map(|(_, input)| {
4355 let canonical = input.canonicalize().map_err(|source| WasmBuildError::Io {
4356 operation: "resolve Cargo input boundary",
4357 path: input.clone(),
4358 source,
4359 })?;
4360 let metadata = fs::metadata(&canonical).map_err(|source| WasmBuildError::Io {
4361 operation: "inspect Cargo input boundary",
4362 path: canonical.clone(),
4363 source,
4364 })?;
4365 Ok((canonical, metadata.is_dir()))
4366 })
4367 .collect::<Result<Vec<_>, WasmBuildError>>()?;
4368 let safe_generated_roots = std::iter::once(spec.target_dir.clone())
4369 .chain(std::iter::once(spec.workspace_root.join("target")))
4370 .chain(
4371 inputs
4372 .iter()
4373 .filter(|(_, path)| path.is_dir())
4374 .map(|(_, path)| path.join("target")),
4375 )
4376 .filter_map(|path| canonicalize_allow_missing(&path).ok())
4377 .filter(|root| {
4378 !resolved_inputs
4379 .iter()
4380 .any(|(input, _is_directory)| input.starts_with(root))
4381 })
4382 .collect::<Vec<_>>();
4383 if safe_generated_roots
4384 .iter()
4385 .any(|root| shared_target.starts_with(root))
4386 {
4387 return Ok(());
4388 }
4389
4390 for (input, is_directory) in resolved_inputs {
4391 if shared_target == input
4392 || (is_directory && shared_target.starts_with(&input))
4393 || input.starts_with(&shared_target)
4394 {
4395 return Err(WasmBuildError::InvalidSpec {
4396 message: format!(
4397 "shared incremental target {} must not overlap exact Cargo inputs unless it is inside a generated target directory",
4398 shared_target.display()
4399 ),
4400 });
4401 }
4402 }
4403 Ok(())
4404}
4405
4406fn canonicalize_allow_missing(path: &Path) -> io::Result<PathBuf> {
4407 let absolute = if path.is_absolute() {
4408 path.to_owned()
4409 } else {
4410 std::env::current_dir()?.join(path)
4411 };
4412 let mut unresolved = Vec::<OsString>::new();
4413 let mut existing = absolute.as_path();
4414 loop {
4415 match existing.canonicalize() {
4416 Ok(mut canonical) => {
4417 for component in unresolved.into_iter().rev() {
4418 canonical.push(component);
4419 }
4420 return Ok(canonical);
4421 }
4422 Err(error) if error.kind() == io::ErrorKind::NotFound => {
4423 let Some(name) = existing.file_name() else {
4424 return Err(error);
4425 };
4426 unresolved.push(name.to_owned());
4427 existing = existing.parent().ok_or(error)?;
4428 }
4429 Err(error) => return Err(error),
4430 }
4431 }
4432}
4433
4434fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
4435 let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
4436 return None;
4437 };
4438 Some(if target_dir.is_absolute() {
4439 target_dir.clone()
4440 } else {
4441 spec.workspace_root.join(target_dir)
4442 })
4443}
4444
4445fn shared_incremental_target_exists(
4446 spec: &WasmBuildSpec,
4447 operation: &'static str,
4448) -> Result<bool, WasmBuildError> {
4449 let target_dir =
4450 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
4451 message: "shared incremental target is not configured".to_owned(),
4452 })?;
4453 match fs::symlink_metadata(&target_dir) {
4454 Ok(metadata) if metadata.is_dir() => Ok(true),
4455 Ok(_) => Err(WasmBuildError::InvalidSpec {
4456 message: format!(
4457 "shared incremental Cargo target {} must be a directory",
4458 target_dir.display()
4459 ),
4460 }),
4461 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
4462 Err(source) => Err(WasmBuildError::Io {
4463 operation,
4464 path: target_dir,
4465 source,
4466 }),
4467 }
4468}
4469
4470fn effective_environment(spec: &WasmBuildSpec) -> BTreeMap<OsString, Option<OsString>> {
4471 let mut names = spec.inherited_env.clone();
4472 names.extend(AUTOMATIC_ENVIRONMENT.iter().map(OsString::from));
4473 let mut environment = names
4474 .into_iter()
4475 .map(|name| {
4476 let value = std::env::var_os(&name);
4477 (name, value)
4478 })
4479 .collect::<BTreeMap<_, _>>();
4480 for (key, value) in &spec.extra_env {
4481 environment.insert(key.clone(), Some(value.clone()));
4482 }
4483 environment
4484}
4485
4486fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
4487 for (key, value) in &spec.extra_env {
4488 command.env(key, value);
4489 }
4490}
4491
4492fn run_cargo_build(
4493 spec: &WasmBuildSpec,
4494 build_target_dir: &Path,
4495 progress: &mut ProgressReporter<'_>,
4496) -> Result<(), WasmBuildError> {
4497 let mut command = Command::new(&spec.cargo_program);
4498 command
4499 .current_dir(&spec.workspace_root)
4500 .env("CARGO_TARGET_DIR", build_target_dir)
4501 .args(["build", "--target", &spec.target])
4502 .args(&spec.cargo_profile_args);
4503 apply_command_environment(&mut command, spec);
4504 for package in &spec.packages {
4505 command.args(["-p", package]);
4506 }
4507
4508 if !progress.is_observed() {
4509 let output = command
4510 .output()
4511 .map_err(|source| WasmBuildError::CommandSpawn {
4512 phase: WasmBuildPhase::CargoBuild,
4513 program: spec.cargo_program.clone(),
4514 source,
4515 })?;
4516 return ensure_command_success(WasmBuildPhase::CargoBuild, output).map(|_| ());
4517 }
4518
4519 run_observed_cargo_build(spec, build_target_dir, command, progress)
4520}
4521
4522fn run_observed_cargo_build(
4523 spec: &WasmBuildSpec,
4524 build_target_dir: &Path,
4525 mut command: Command,
4526 progress: &mut ProgressReporter<'_>,
4527) -> Result<(), WasmBuildError> {
4528 command.stdout(Stdio::piped()).stderr(Stdio::piped());
4529 let started = Instant::now();
4530 let child = command
4531 .spawn()
4532 .map_err(|source| WasmBuildError::CommandSpawn {
4533 phase: WasmBuildPhase::CargoBuild,
4534 program: spec.cargo_program.clone(),
4535 source,
4536 })?;
4537 let mut child = ObservedChild::new(child);
4538 progress.emit(WasmBuildProgressEvent::CargoStarted {
4539 target_dir: build_target_dir.to_owned(),
4540 });
4541
4542 let stdout = child
4543 .child_mut()
4544 .stdout
4545 .take()
4546 .expect("Cargo stdout must be piped");
4547 let stderr = child
4548 .child_mut()
4549 .stderr
4550 .take()
4551 .expect("Cargo stderr must be piped");
4552 let (sender, chunks) = mpsc::channel();
4553 let stdout_sender = sender.clone();
4554 let stdout_reader = thread::spawn(move || {
4555 read_process_output(stdout, WasmBuildOutputStream::Stdout, stdout_sender)
4556 });
4557 let stderr_reader =
4558 thread::spawn(move || read_process_output(stderr, WasmBuildOutputStream::Stderr, sender));
4559
4560 let captured = capture_observed_cargo_output(chunks, progress, started);
4561
4562 let status = child.wait().map_err(|source| WasmBuildError::Io {
4563 operation: "wait for observed cargo build",
4564 path: PathBuf::from(&spec.cargo_program),
4565 source,
4566 })?;
4567 join_output_reader(
4568 stdout_reader,
4569 "read observed cargo stdout",
4570 &spec.cargo_program,
4571 )?;
4572 join_output_reader(
4573 stderr_reader,
4574 "read observed cargo stderr",
4575 &spec.cargo_program,
4576 )?;
4577 let elapsed = started.elapsed();
4578 progress.emit(WasmBuildProgressEvent::CargoFinished {
4579 success: status.success(),
4580 code: status.code(),
4581 elapsed,
4582 });
4583
4584 ensure_command_success(
4585 WasmBuildPhase::CargoBuild,
4586 Output {
4587 status,
4588 stdout: captured.stdout,
4589 stderr: captured.stderr,
4590 },
4591 )
4592 .map(|_| ())
4593}
4594
4595struct CapturedProcessOutput {
4596 stdout: Vec<u8>,
4597 stderr: Vec<u8>,
4598}
4599
4600fn capture_observed_cargo_output(
4601 chunks: mpsc::Receiver<ProcessOutputChunk>,
4602 progress: &mut ProgressReporter<'_>,
4603 started: Instant,
4604) -> CapturedProcessOutput {
4605 let mut stdout = Vec::new();
4606 let mut stderr = Vec::new();
4607 loop {
4608 let message = match progress.heartbeat_due_in() {
4609 Some(wait) => match chunks.recv_timeout(wait) {
4610 Ok(chunk) => Some(chunk),
4611 Err(RecvTimeoutError::Timeout) => {
4612 progress.emit_heartbeat(WasmBuildProgressPhase::CargoBuild, started.elapsed());
4613 None
4614 }
4615 Err(RecvTimeoutError::Disconnected) => break,
4616 },
4617 None => match chunks.recv() {
4618 Ok(chunk) => Some(chunk),
4619 Err(_) => break,
4620 },
4621 };
4622 let Some(chunk) = message else {
4623 continue;
4624 };
4625 match chunk.stream {
4626 WasmBuildOutputStream::Stdout => stdout.extend_from_slice(&chunk.bytes),
4627 WasmBuildOutputStream::Stderr => stderr.extend_from_slice(&chunk.bytes),
4628 }
4629 if progress.config.emit_cargo_output {
4630 progress.emit(WasmBuildProgressEvent::CargoOutput {
4631 stream: chunk.stream,
4632 bytes: chunk.bytes,
4633 });
4634 }
4635 }
4636 CapturedProcessOutput { stdout, stderr }
4637}
4638
4639#[derive(Debug)]
4640struct ProcessOutputChunk {
4641 stream: WasmBuildOutputStream,
4642 bytes: Vec<u8>,
4643}
4644
4645fn read_process_output<R: io::Read>(
4646 mut reader: R,
4647 stream: WasmBuildOutputStream,
4648 sender: mpsc::Sender<ProcessOutputChunk>,
4649) -> io::Result<()> {
4650 let mut buffer = [0_u8; 8 * 1024];
4651 loop {
4652 let count = reader.read(&mut buffer)?;
4653 if count == 0 {
4654 return Ok(());
4655 }
4656 if sender
4657 .send(ProcessOutputChunk {
4658 stream,
4659 bytes: buffer[..count].to_vec(),
4660 })
4661 .is_err()
4662 {
4663 return Ok(());
4664 }
4665 }
4666}
4667
4668fn join_output_reader(
4669 reader: thread::JoinHandle<io::Result<()>>,
4670 operation: &'static str,
4671 cargo_program: &OsStr,
4672) -> Result<(), WasmBuildError> {
4673 let result = reader.join().map_err(|_| WasmBuildError::Io {
4674 operation,
4675 path: PathBuf::from(cargo_program),
4676 source: io::Error::other("Cargo output reader panicked"),
4677 })?;
4678 result.map_err(|source| WasmBuildError::Io {
4679 operation,
4680 path: PathBuf::from(cargo_program),
4681 source,
4682 })
4683}
4684
4685struct ObservedChild(Option<Child>);
4686
4687impl ObservedChild {
4688 const fn new(child: Child) -> Self {
4689 Self(Some(child))
4690 }
4691
4692 const fn child_mut(&mut self) -> &mut Child {
4693 self.0.as_mut().expect("observed child must be present")
4694 }
4695
4696 fn wait(&mut self) -> io::Result<ExitStatus> {
4697 let status = self.child_mut().wait()?;
4698 self.0.take();
4699 Ok(status)
4700 }
4701}
4702
4703impl Drop for ObservedChild {
4704 fn drop(&mut self) {
4705 if let Some(mut child) = self.0.take() {
4706 let _ = child.kill();
4707 let _ = child.wait();
4708 }
4709 }
4710}
4711
4712fn ensure_command_success(phase: WasmBuildPhase, output: Output) -> Result<Output, WasmBuildError> {
4713 if output.status.success() {
4714 return Ok(output);
4715 }
4716 Err(WasmBuildError::CommandFailed {
4717 phase,
4718 status: output.status,
4719 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
4720 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
4721 })
4722}
4723
4724fn expected_artifacts(spec: &WasmBuildSpec, target_dir: &Path) -> Vec<PathBuf> {
4725 let mut packages = spec.packages.iter().map(String::as_str).collect::<Vec<_>>();
4726 packages.sort_unstable();
4727 packages.dedup();
4728 packages
4729 .into_iter()
4730 .map(|package| {
4731 if spec.target == DEFAULT_TARGET {
4732 wasm_path(target_dir, package, &spec.profile_target_dir)
4733 } else {
4734 target_dir
4735 .join(&spec.target)
4736 .join(&spec.profile_target_dir)
4737 .join(format!("{package}.wasm"))
4738 }
4739 })
4740 .collect()
4741}
4742
4743fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
4744 spec.target_dir
4745 .join(".ic-testkit/wasm-targets")
4746 .join(fingerprint.to_hex())
4747}
4748
4749fn artifact_set_matches(artifacts: &[PathBuf], fingerprint: InputDigest) -> bool {
4750 artifacts.iter().all(|path| {
4751 fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
4752 && cache_stamp_matches(path, fingerprint)
4753 })
4754}
4755
4756fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
4757 artifacts
4758 .iter()
4759 .filter(|path| {
4760 fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
4761 })
4762 .cloned()
4763 .collect()
4764}
4765
4766fn cache_stamp_matches(artifact: &Path, fingerprint: InputDigest) -> bool {
4767 let stamp_path = artifact_stamp_path(artifact);
4768 let Ok(expected) = artifact_stamp_contents(artifact, fingerprint) else {
4769 return false;
4770 };
4771 fs::read_to_string(stamp_path).is_ok_and(|stamp| stamp == expected)
4772}
4773
4774fn artifact_stamp_path(artifact: &Path) -> PathBuf {
4775 let mut name = artifact
4776 .file_name()
4777 .map_or_else(|| OsString::from("artifact"), OsString::from);
4778 name.push(".ic-testkit-build");
4779 artifact.with_file_name(name)
4780}
4781
4782fn artifact_stamp_contents(artifact: &Path, fingerprint: InputDigest) -> io::Result<String> {
4783 let (_, artifact_digest) = digest_file("wasm-artifact-v1", artifact)?;
4784 Ok(format!(
4785 "{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\nartifact-sha256:{artifact_digest}\n"
4786 ))
4787}
4788
4789fn publish_artifact_stamps(
4790 artifacts: &[PathBuf],
4791 fingerprint: InputDigest,
4792) -> Result<(), WasmBuildError> {
4793 for artifact in artifacts {
4794 let stamp_path = artifact_stamp_path(artifact);
4795 let stamp = artifact_stamp_contents(artifact, fingerprint).map_err(|source| {
4796 WasmBuildError::Io {
4797 operation: "hash built Wasm artifact",
4798 path: artifact.clone(),
4799 source,
4800 }
4801 })?;
4802 write_atomic(&stamp_path, stamp.as_bytes()).map_err(|source| WasmBuildError::Io {
4803 operation: "publish Wasm build stamp",
4804 path: stamp_path,
4805 source,
4806 })?;
4807 }
4808 Ok(())
4809}
4810
4811fn materialize_artifacts(
4812 cached_artifacts: &[PathBuf],
4813 artifacts: &[PathBuf],
4814 fingerprint: InputDigest,
4815) -> Result<(), WasmBuildError> {
4816 for (cached, artifact) in cached_artifacts.iter().zip(artifacts) {
4817 copy_file_atomic(cached, artifact).map_err(|source| WasmBuildError::Io {
4818 operation: "publish Wasm artifact",
4819 path: artifact.clone(),
4820 source,
4821 })?;
4822 }
4823 publish_artifact_stamps(artifacts, fingerprint)
4824}
4825
4826fn copy_wasm_artifacts(
4827 source_artifacts: &[PathBuf],
4828 cached_artifacts: &[PathBuf],
4829) -> Result<(), WasmBuildError> {
4830 for (source, cached) in source_artifacts.iter().zip(cached_artifacts) {
4831 copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
4832 operation: "cache shared-incremental Wasm artifact",
4833 path: cached.clone(),
4834 source: source_error,
4835 })?;
4836 }
4837 Ok(())
4838}
4839
4840fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
4841 fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
4842 operation,
4843 path: path.to_owned(),
4844 source,
4845 })
4846}
4847
4848fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
4849 if path.exists() {
4850 inputs.push((PathBuf::from(label), path));
4851 }
4852}
4853
4854fn required_string(value: &Value, field: &str) -> Result<String, WasmBuildError> {
4855 value
4856 .get(field)
4857 .and_then(Value::as_str)
4858 .map(str::to_owned)
4859 .ok_or_else(|| invalid_metadata(&format!("Cargo metadata field `{field}` is missing")))
4860}
4861
4862fn optional_string(value: &Value, field: &str) -> Result<Option<String>, WasmBuildError> {
4863 match value.get(field) {
4864 None | Some(Value::Null) => Ok(None),
4865 Some(Value::String(value)) => Ok(Some(value.clone())),
4866 Some(_) => Err(invalid_metadata(&format!(
4867 "Cargo metadata field `{field}` is not a string or null"
4868 ))),
4869 }
4870}
4871
4872fn invalid_metadata(message: &str) -> WasmBuildError {
4873 WasmBuildError::InvalidMetadata {
4874 message: message.to_owned(),
4875 }
4876}
4877
4878impl WasmBuildError {
4879 fn indicates_input_change(&self) -> bool {
4880 match self {
4881 Self::InputsChangedDuringBuild { .. } => true,
4882 Self::FailedBuildCleanup { build_error, .. } => build_error.indicates_input_change(),
4883 _ => false,
4884 }
4885 }
4886}
4887
4888impl std::fmt::Display for WasmBuildPhase {
4889 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4890 formatter.write_str(match self {
4891 Self::CargoMetadata => "cargo metadata",
4892 Self::CargoIdentity => "Cargo identity",
4893 Self::RustcIdentity => "Rust compiler identity",
4894 Self::CargoBuild => "cargo build",
4895 })
4896 }
4897}
4898
4899impl std::fmt::Display for WasmBuildProgressPhase {
4900 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4901 formatter.write_str(match self {
4902 Self::ExactCacheLock => "exact cache lock",
4903 Self::CargoIdentity => "Cargo identity",
4904 Self::RustcIdentity => "Rust compiler identity",
4905 Self::CargoMetadata => "Cargo metadata",
4906 Self::InputDiscovery => "input discovery",
4907 Self::ContentHashing => "content hashing",
4908 Self::SharedTargetLock => "shared target lock",
4909 Self::SharedTargetMaintenance => "shared target maintenance",
4910 Self::CargoBuild => "Cargo build",
4911 Self::ArtifactPublication => "artifact publication",
4912 Self::ExactCacheMaintenance => "exact cache maintenance",
4913 })
4914 }
4915}
4916
4917impl std::fmt::Display for WasmBuildError {
4918 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4919 match self {
4920 Self::InvalidSpec { message } => {
4921 write!(formatter, "invalid Wasm build spec: {message}")
4922 }
4923 Self::Io {
4924 operation,
4925 path,
4926 source,
4927 } => write!(
4928 formatter,
4929 "failed to {operation} at {}: {source}",
4930 path.display()
4931 ),
4932 Self::CommandSpawn {
4933 phase,
4934 program,
4935 source,
4936 } => write!(
4937 formatter,
4938 "failed to launch {phase} using `{}`: {source}",
4939 program.to_string_lossy(),
4940 ),
4941 Self::CommandFailed {
4942 phase,
4943 status,
4944 stdout,
4945 stderr,
4946 } => write!(
4947 formatter,
4948 "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
4949 ),
4950 Self::InvalidMetadata { message } => {
4951 write!(formatter, "invalid Cargo metadata: {message}")
4952 }
4953 Self::InvalidCargoConfiguration { path, message } => write!(
4954 formatter,
4955 "invalid Cargo configuration at {}: {message}",
4956 path.display(),
4957 ),
4958 Self::MissingArtifacts { paths } => write!(
4959 formatter,
4960 "cargo build succeeded without producing: {}",
4961 paths
4962 .iter()
4963 .map(|path| path.display().to_string())
4964 .collect::<Vec<_>>()
4965 .join(", "),
4966 ),
4967 Self::InputsChangedDuringBuild { before, after } => write!(
4968 formatter,
4969 "Wasm build inputs changed while Cargo was running: {before} -> {after}",
4970 ),
4971 Self::PreparedInputSnapshotInvalidated => formatter.write_str(
4972 "the prepared Wasm input snapshot was invalidated before artifact publication",
4973 ),
4974 Self::FailedBuildCleanup {
4975 build_error,
4976 path,
4977 source,
4978 } => write!(
4979 formatter,
4980 "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
4981 path.display(),
4982 ),
4983 }
4984 }
4985}
4986
4987impl std::error::Error for WasmBuildError {
4988 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
4989 match self {
4990 Self::Io { source, .. }
4991 | Self::CommandSpawn { source, .. }
4992 | Self::FailedBuildCleanup { source, .. } => Some(source),
4993 _ => None,
4994 }
4995 }
4996}
4997
4998#[cfg(test)]
4999mod tests;