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