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