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