1use ic_host_process::{
2 child::{CleanupPolicy, OwnedChild},
3 tool::{
4 CompletedExecution, ExecutionError, ExecutionEvidence, ExecutionFailure,
5 ExecutionOperation, OutputLimit, OutputLimits, OutputStream, SuccessfulExit, ToolError,
6 capture_command, communicate_child, communicate_child_with_observer,
7 },
8};
9use serde_json::Value;
10use std::{
11 cell::RefCell,
12 collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
13 ffi::{OsStr, OsString},
14 fs::{self, File},
15 io,
16 path::{Component, Path, PathBuf},
17 process::{Command, ExitStatus, Stdio},
18 sync::{
19 Arc, RwLock,
20 atomic::{AtomicUsize, Ordering},
21 mpsc::{self, RecvTimeoutError},
22 },
23 thread,
24 time::{Duration, Instant, SystemTime},
25};
26use toml::Value as TomlValue;
27
28use super::{
29 cache_fs::{
30 ArtifactCacheMaintenance, ArtifactCachePrunePolicy, ArtifactCachePruneReport, CacheFsError,
31 RetainedCacheEntry, cache_entry_last_used, cache_maintenance_due,
32 canonicalize_allow_missing, directory_logical_size,
33 ensure_cache_directory_tag as ensure_cache_tag, is_sha256_directory, lock_cache_file,
34 lock_cache_file_with_wait_observer, perform_scheduled_cache_maintenance,
35 prune_direct_child_directories, record_cache_entry_use as record_entry_use,
36 record_cache_maintenance, remove_path_if_present, remove_unretained_entry,
37 },
38 digest::{
39 FileDigest, InputDigest, InputHasher, LabeledPathDigestCache, copy_file_atomic,
40 destination_matches_bytes, digest_bytes, digest_file, digest_labeled_paths_composable,
41 materialize_verified_output, os_bytes, read_stamp_with_limit,
42 },
43};
44
45const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
46const CARGO_DIAGNOSTIC_BYTES: usize = 1024 * 1024;
48const TOOL_IDENTITY_BYTES: usize = 64 * 1024;
49const CARGO_METADATA_BYTES: usize = 16 * 1024 * 1024;
50
51const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
52const AUTOMATIC_ENVIRONMENT: &[&str] = &[
53 "CARGO_BUILD_RUSTC",
54 "CARGO_ENCODED_RUSTFLAGS",
55 "RUSTC",
56 "RUSTC_WRAPPER",
57 "RUSTC_WORKSPACE_WRAPPER",
58 "RUSTFLAGS",
59 "RUSTUP_TOOLCHAIN",
60];
61
62#[derive(Clone, Debug, Eq, PartialEq)]
70pub struct WasmBuildSpec {
71 workspace_root: PathBuf,
72 target_dir: PathBuf,
73 packages: Vec<String>,
74 example: Option<String>,
75 profile_target_dir: String,
76 cargo_profile_args: Vec<OsString>,
77 extra_env: BTreeMap<OsString, OsString>,
78 inherited_env: BTreeSet<OsString>,
79 additional_inputs: Vec<PathBuf>,
80 target: String,
81 cargo_program: OsString,
82 rustc_program: OsString,
83 cache_mode: WasmBuildCacheMode,
84 prune_policy: Option<ArtifactCachePrunePolicy>,
85 prune_interval: Option<Duration>,
86 shared_incremental_maintenance_config: Option<SharedIncrementalTargetMaintenanceConfig>,
87}
88
89#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
91pub enum SharedIncrementalTargetMaintenanceFailureMode {
92 #[default]
94 Strict,
95 BestEffort,
97}
98
99#[derive(Clone, Copy, Debug, Eq, PartialEq)]
101pub struct SharedIncrementalTargetMaintenanceConfig {
102 policy: SharedIncrementalTargetPrunePolicy,
103 minimum_interval: Duration,
104 failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
105}
106
107#[non_exhaustive]
109#[derive(Clone, Debug, Eq, PartialEq)]
110pub enum WasmBuildCacheMode {
111 Isolated,
113 SharedIncremental {
115 target_dir: PathBuf,
117 },
118}
119
120#[derive(Clone, Debug, Eq, PartialEq)]
122pub enum WasmBuildOutcome {
123 Built(WasmBuildRecord),
125 Reused(WasmBuildRecord),
127}
128
129#[derive(Clone, Debug, Eq, PartialEq)]
131pub struct WasmBuildRecord {
132 fingerprint: InputDigest,
133 input_digest: InputDigest,
134 retention: RetainedCacheEntry,
135 artifacts: Vec<PathBuf>,
136 timings: WasmBuildTimings,
137 maintenance: Option<ArtifactCacheMaintenance>,
138 shared_incremental_maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
139}
140
141#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
143pub struct WasmBuildTimings {
144 lock_wait: Duration,
145 shared_incremental_lock_wait: Option<Duration>,
146 input_resolution: WasmInputResolutionTimings,
147 cargo_build: Option<Duration>,
148 cache_maintenance: Option<Duration>,
149 total: Duration,
150}
151
152#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
154pub struct WasmInputResolutionTimings {
155 tool_identity: Duration,
156 cargo_metadata: Duration,
157 input_discovery: Duration,
158 content_hashing: Duration,
159 total: Duration,
160}
161
162#[non_exhaustive]
164#[derive(Clone, Copy, Debug, Eq, PartialEq)]
165pub enum WasmBuildFailurePhase {
166 Specification,
168 ExactCacheCoordination,
170 ToolIdentity,
172 CargoMetadata,
174 InputDiscovery,
176 ContentHashing,
178 SharedTargetCoordination,
180 SharedTargetMaintenance,
182 CargoBuild,
184 ArtifactPublication,
186 ExactCacheMaintenance,
188 Cleanup,
190}
191
192#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
194pub struct WasmBuildFailureTimings {
195 exact_cache_coordination: Duration,
196 shared_target_coordination: Option<Duration>,
197 input_resolution: WasmInputResolutionTimings,
198 shared_target_maintenance: Option<Duration>,
199 cargo_build: Option<Duration>,
200 artifact_publication: Option<Duration>,
201 exact_cache_maintenance: Option<Duration>,
202 cleanup: Option<Duration>,
203 total: Duration,
204}
205
206#[derive(Clone, Copy, Debug, Eq, PartialEq)]
208pub struct WasmBuildFailureDetails {
209 phase: WasmBuildFailurePhase,
210 timings: WasmBuildFailureTimings,
211}
212
213#[derive(Clone, Debug, Eq, PartialEq)]
215pub struct CargoBuildInput {
216 label: PathBuf,
217 path: PathBuf,
218}
219
220#[derive(Clone, Debug, Eq, PartialEq)]
227pub struct ResolvedCargoBuildInputs {
228 fingerprint: InputDigest,
229 input_digest: InputDigest,
230 validation_digest: InputDigest,
231 inputs: Arc<[CargoBuildInput]>,
232 exclusions: Arc<[PathBuf]>,
233 wasm_artifact_error: Option<String>,
234 artifact_paths: Arc<[PathBuf]>,
235 timings: WasmInputResolutionTimings,
236}
237
238pub(super) enum WasmBuildInputReuse<'reuse> {
239 Session(&'reuse mut WasmBuildSessionState),
240 Snapshot(&'reuse WasmBuildInputSnapshotState),
241}
242
243pub(super) struct WasmBuildBatchInputResolver<'a, 'session> {
244 specs: Vec<&'a WasmBuildSpec>,
245 groups: Vec<BatchResolutionGroup>,
246 group_by_index: Vec<usize>,
247 resolved:
248 Vec<Option<Result<ResolvedCargoBuildInputs, (WasmBuildFailurePhase, WasmBuildError)>>>,
249 reuse: Option<WasmBuildInputReuse<'session>>,
250 metrics: WasmBuildBatchInputMetrics,
251}
252
253pub(super) struct WasmBuildSessionState {
254 snapshots: Vec<(WasmBuildSpec, ResolvedCargoBuildInputs)>,
255 digest_cache: LabeledPathDigestCache,
256 snapshot_reuses: usize,
257 invalidated: bool,
258}
259
260pub(super) struct WasmBuildInputSnapshotState {
261 snapshots: Vec<(WasmBuildSpec, ResolvedCargoBuildInputs)>,
262 preparation_metrics: WasmBuildBatchInputMetrics,
263 preparation_timings: WasmInputResolutionTimings,
264 reader_reuses: AtomicUsize,
265 invalidation: Arc<RwLock<bool>>,
266}
267
268pub(super) struct WasmBuildBatchAttempt {
270 pub(super) result: Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)>,
271}
272
273struct BatchResolutionGroup {
274 indexes: Vec<usize>,
275}
276
277struct ResolvedLocalInputs {
278 validation_inputs: Vec<(PathBuf, PathBuf)>,
279 workspace_projection: Option<InputDigest>,
280 wasm_artifact_error: Option<String>,
281}
282
283#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
284pub(super) struct WasmBuildBatchInputMetrics {
285 pub(super) runs: usize,
286 pub(super) reuses: usize,
287 pub(super) session_reuses: usize,
288 pub(super) prepared_reuses: usize,
289}
290
291#[derive(Eq, PartialEq)]
292struct BatchResolutionKey<'a> {
293 workspace_root: &'a Path,
294 cargo_program: &'a OsStr,
295 rustc_program: &'a OsStr,
296 metadata_arguments: Vec<OsString>,
297 environment: BTreeMap<OsString, Option<OsString>>,
298}
299
300#[derive(Clone, Debug, Eq, PartialEq)]
302pub struct SharedIncrementalTargetInspection {
303 target_dir: PathBuf,
304 logical_size_bytes: u64,
305 last_used: SystemTime,
306 lock_wait: Duration,
307}
308
309#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
317pub struct SharedIncrementalTargetPrunePolicy {
318 max_age: Option<Duration>,
319 max_size_bytes: Option<u64>,
320}
321
322#[derive(Clone, Debug, Eq, PartialEq)]
324pub struct SharedIncrementalTargetMaintenance {
325 target_dir: PathBuf,
326 logical_size_bytes_before: u64,
327 logical_size_bytes_after: u64,
328 last_used_before: SystemTime,
329 cleared: bool,
330 lock_wait: Duration,
331 maintenance: Duration,
332}
333
334#[non_exhaustive]
336#[derive(Clone, Debug, Eq, PartialEq)]
337pub enum SharedIncrementalTargetMaintenanceOutcome {
338 Missing {
340 target_dir: PathBuf,
342 },
343 Skipped {
345 target_dir: PathBuf,
347 lock_wait: Duration,
349 schedule_check: Duration,
351 },
352 Performed {
354 maintenance: SharedIncrementalTargetMaintenance,
356 schedule_check: Duration,
358 },
359 Failed {
361 target_dir: PathBuf,
363 lock_wait: Duration,
365 message: String,
367 },
368}
369
370#[derive(Clone, Copy, Debug, Eq, PartialEq)]
372pub struct WasmBuildProgressConfig {
373 heartbeat_interval: Option<Duration>,
374 emit_cargo_output: bool,
375}
376
377#[derive(Clone, Copy, Debug, Eq, PartialEq)]
379pub enum WasmBuildOutputStream {
380 Stdout,
382 Stderr,
384}
385
386#[derive(Clone, Copy, Debug, Eq, PartialEq)]
388pub enum WasmBuildProgressOutcome {
389 Built,
391 Reused,
393}
394
395#[non_exhaustive]
397#[derive(Clone, Copy, Debug, Eq, PartialEq)]
398pub enum WasmBuildProgressPhase {
399 ExactCacheLock,
401 CargoIdentity,
403 RustcIdentity,
405 CargoMetadata,
407 InputDiscovery,
409 ContentHashing,
411 SharedTargetLock,
413 SharedTargetMaintenance,
415 CargoBuild,
417 ArtifactPublication,
419 ExactCacheMaintenance,
421}
422
423#[non_exhaustive]
425#[derive(Clone, Debug, Eq, PartialEq)]
426pub enum WasmBuildProgressEvent {
427 Started,
429 InputsResolved {
431 fingerprint: InputDigest,
433 input_digest: InputDigest,
435 elapsed: Duration,
437 },
438 CacheMiss {
440 fingerprint: InputDigest,
442 },
443 CacheHit {
445 fingerprint: InputDigest,
447 },
448 SharedTargetLockStarted {
450 target_dir: PathBuf,
452 },
453 SharedTargetLockAcquired {
455 target_dir: PathBuf,
457 wait: Duration,
459 },
460 SharedTargetMaintenanceStarted {
462 target_dir: PathBuf,
464 },
465 SharedTargetMaintenanceFinished {
467 outcome: SharedIncrementalTargetMaintenanceOutcome,
469 },
470 CargoStarted {
472 target_dir: PathBuf,
474 },
475 CargoOutput {
477 stream: WasmBuildOutputStream,
479 bytes: Vec<u8>,
481 },
482 Heartbeat {
484 phase: WasmBuildProgressPhase,
486 elapsed: Duration,
488 },
489 CargoFinished {
491 success: bool,
493 code: Option<i32>,
495 elapsed: Duration,
497 },
498 Finished {
500 outcome: WasmBuildProgressOutcome,
502 fingerprint: InputDigest,
504 elapsed: Duration,
506 },
507}
508
509impl Default for WasmBuildProgressConfig {
510 fn default() -> Self {
511 Self {
512 heartbeat_interval: Some(Duration::from_secs(10)),
513 emit_cargo_output: true,
514 }
515 }
516}
517
518impl WasmBuildProgressConfig {
519 #[must_use]
521 pub fn new() -> Self {
522 Self::default()
523 }
524
525 #[must_use]
529 pub const fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
530 self.heartbeat_interval = Some(interval);
531 self
532 }
533
534 #[must_use]
536 pub const fn without_heartbeats(mut self) -> Self {
537 self.heartbeat_interval = None;
538 self
539 }
540
541 #[must_use]
547 pub const fn with_cargo_output(mut self, emit: bool) -> Self {
548 self.emit_cargo_output = emit;
549 self
550 }
551
552 #[must_use]
554 pub const fn heartbeat_interval(self) -> Option<Duration> {
555 self.heartbeat_interval
556 }
557
558 #[must_use]
560 pub const fn emits_cargo_output(self) -> bool {
561 self.emit_cargo_output
562 }
563}
564
565struct ProgressReporter<'a> {
566 config: WasmBuildProgressConfig,
567 observer: Option<&'a mut dyn FnMut(WasmBuildProgressEvent)>,
568 last_event: Instant,
569 failure_phase: Option<WasmBuildFailurePhase>,
570 failure_timings: WasmBuildFailureTimings,
571}
572
573impl ProgressReporter<'_> {
574 fn silent() -> Self {
575 Self {
576 config: WasmBuildProgressConfig {
577 heartbeat_interval: None,
578 emit_cargo_output: false,
579 },
580 observer: None,
581 last_event: Instant::now(),
582 failure_phase: None,
583 failure_timings: WasmBuildFailureTimings::default(),
584 }
585 }
586
587 fn observed(
588 config: WasmBuildProgressConfig,
589 observer: &'_ mut dyn FnMut(WasmBuildProgressEvent),
590 ) -> ProgressReporter<'_> {
591 ProgressReporter {
592 config,
593 observer: Some(observer),
594 last_event: Instant::now(),
595 failure_phase: None,
596 failure_timings: WasmBuildFailureTimings::default(),
597 }
598 }
599
600 fn emit(&mut self, event: WasmBuildProgressEvent) {
601 if let Some(observer) = &mut self.observer {
602 observer(event);
603 self.last_event = Instant::now();
604 }
605 }
606
607 const fn is_observed(&self) -> bool {
608 self.observer.is_some()
609 }
610
611 fn heartbeat_due_in(&self) -> Option<Duration> {
612 self.config
613 .heartbeat_interval
614 .map(|interval| interval.saturating_sub(self.last_event.elapsed()))
615 }
616
617 fn emit_heartbeat(&mut self, phase: WasmBuildProgressPhase, elapsed: Duration) {
618 self.emit(WasmBuildProgressEvent::Heartbeat { phase, elapsed });
619 }
620
621 fn emit_heartbeat_if_due(&mut self, phase: WasmBuildProgressPhase, elapsed: Duration) {
622 if self.heartbeat_due_in() == Some(Duration::ZERO) {
623 self.emit_heartbeat(phase, elapsed);
624 }
625 }
626
627 fn run_phase<T, F>(&mut self, phase: WasmBuildProgressPhase, operation: F) -> T
628 where
629 T: Send,
630 F: FnOnce() -> T + Send,
631 {
632 let started = Instant::now();
633 self.begin_phase(progress_failure_phase(phase));
634 let result = if !self.is_observed() || self.config.heartbeat_interval.is_none() {
635 operation()
636 } else {
637 thread::scope(|scope| {
638 let (finished, completion) = mpsc::sync_channel(0);
639 let worker = scope.spawn(move || {
640 let result = operation();
641 let _ = finished.send(());
642 result
643 });
644 loop {
645 let wait = self
646 .heartbeat_due_in()
647 .expect("observed phase must have a heartbeat interval");
648 match completion.recv_timeout(wait) {
649 Ok(()) | Err(RecvTimeoutError::Disconnected) => {
650 return worker
651 .join()
652 .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
653 }
654 Err(RecvTimeoutError::Timeout) => {
655 self.emit_heartbeat(phase, started.elapsed());
656 }
657 }
658 }
659 })
660 };
661 self.record_phase(progress_failure_phase(phase), started.elapsed());
662 result
663 }
664
665 const fn begin_phase(&mut self, phase: WasmBuildFailurePhase) {
666 self.failure_phase = Some(phase);
667 }
668
669 fn record_phase(&mut self, phase: WasmBuildFailurePhase, elapsed: Duration) {
670 self.failure_phase = Some(phase);
671 let timings = &mut self.failure_timings;
672 let timing = match phase {
675 WasmBuildFailurePhase::Specification => return,
676 WasmBuildFailurePhase::ExactCacheCoordination => &mut timings.exact_cache_coordination,
677 WasmBuildFailurePhase::ToolIdentity => &mut timings.input_resolution.tool_identity,
678 WasmBuildFailurePhase::CargoMetadata => &mut timings.input_resolution.cargo_metadata,
679 WasmBuildFailurePhase::InputDiscovery => &mut timings.input_resolution.input_discovery,
680 WasmBuildFailurePhase::ContentHashing => &mut timings.input_resolution.content_hashing,
681 WasmBuildFailurePhase::SharedTargetCoordination => timings
682 .shared_target_coordination
683 .get_or_insert(Duration::ZERO),
684 WasmBuildFailurePhase::SharedTargetMaintenance => timings
685 .shared_target_maintenance
686 .get_or_insert(Duration::ZERO),
687 WasmBuildFailurePhase::CargoBuild => timings.cargo_build.get_or_insert(Duration::ZERO),
688 WasmBuildFailurePhase::ArtifactPublication => {
689 timings.artifact_publication.get_or_insert(Duration::ZERO)
690 }
691 WasmBuildFailurePhase::ExactCacheMaintenance => timings
692 .exact_cache_maintenance
693 .get_or_insert(Duration::ZERO),
694 WasmBuildFailurePhase::Cleanup => timings.cleanup.get_or_insert(Duration::ZERO),
695 };
696 *timing = timing.saturating_add(elapsed);
697 if matches!(
698 phase,
699 WasmBuildFailurePhase::ToolIdentity
700 | WasmBuildFailurePhase::CargoMetadata
701 | WasmBuildFailurePhase::InputDiscovery
702 | WasmBuildFailurePhase::ContentHashing
703 ) {
704 timings.input_resolution.total = timings.input_resolution.total.saturating_add(elapsed);
705 }
706 }
707
708 fn failure_details(&self, error: &WasmBuildError, total: Duration) -> WasmBuildFailureDetails {
709 let phase = self
710 .failure_phase
711 .unwrap_or_else(|| classify_unobserved_failure(error));
712 let mut timings = self.failure_timings;
713 timings.total = total;
714 WasmBuildFailureDetails { phase, timings }
715 }
716}
717
718const fn progress_failure_phase(phase: WasmBuildProgressPhase) -> WasmBuildFailurePhase {
719 match phase {
720 WasmBuildProgressPhase::ExactCacheLock => WasmBuildFailurePhase::ExactCacheCoordination,
721 WasmBuildProgressPhase::CargoIdentity | WasmBuildProgressPhase::RustcIdentity => {
722 WasmBuildFailurePhase::ToolIdentity
723 }
724 WasmBuildProgressPhase::CargoMetadata => WasmBuildFailurePhase::CargoMetadata,
725 WasmBuildProgressPhase::InputDiscovery => WasmBuildFailurePhase::InputDiscovery,
726 WasmBuildProgressPhase::ContentHashing => WasmBuildFailurePhase::ContentHashing,
727 WasmBuildProgressPhase::SharedTargetLock => WasmBuildFailurePhase::SharedTargetCoordination,
728 WasmBuildProgressPhase::SharedTargetMaintenance => {
729 WasmBuildFailurePhase::SharedTargetMaintenance
730 }
731 WasmBuildProgressPhase::CargoBuild => WasmBuildFailurePhase::CargoBuild,
732 WasmBuildProgressPhase::ArtifactPublication => WasmBuildFailurePhase::ArtifactPublication,
733 WasmBuildProgressPhase::ExactCacheMaintenance => {
734 WasmBuildFailurePhase::ExactCacheMaintenance
735 }
736 }
737}
738
739fn classify_unobserved_failure(error: &WasmBuildError) -> WasmBuildFailurePhase {
740 match error {
741 WasmBuildError::InvalidSpec { .. } => WasmBuildFailurePhase::Specification,
742 WasmBuildError::CommandSpawn { phase, .. }
743 | WasmBuildError::CommandFailed { phase, .. } => match phase {
744 WasmBuildPhase::CargoIdentity | WasmBuildPhase::RustcIdentity => {
745 WasmBuildFailurePhase::ToolIdentity
746 }
747 WasmBuildPhase::CargoMetadata => WasmBuildFailurePhase::CargoMetadata,
748 WasmBuildPhase::CargoBuild => WasmBuildFailurePhase::CargoBuild,
749 },
750 WasmBuildError::InvalidMetadata { .. } => WasmBuildFailurePhase::CargoMetadata,
751 WasmBuildError::InvalidCargoConfiguration { .. } => WasmBuildFailurePhase::InputDiscovery,
752 WasmBuildError::MissingArtifacts { .. } => WasmBuildFailurePhase::ArtifactPublication,
753 WasmBuildError::InputsChangedDuringAcquisition { .. } => {
754 WasmBuildFailurePhase::ContentHashing
755 }
756 WasmBuildError::PreparedInputSnapshotInvalidated => {
757 WasmBuildFailurePhase::ArtifactPublication
758 }
759 WasmBuildError::FailedBuildCleanup { .. } => WasmBuildFailurePhase::Cleanup,
760 WasmBuildError::Io { source, .. }
761 if source
762 .get_ref()
763 .is_some_and(<dyn std::error::Error + Send + Sync>::is::<ToolError>) =>
764 {
765 WasmBuildFailurePhase::CargoBuild
766 }
767 WasmBuildError::Io { .. } => WasmBuildFailurePhase::ExactCacheCoordination,
768 }
769}
770
771#[non_exhaustive]
773#[derive(Clone, Copy, Debug, Eq, PartialEq)]
774pub enum WasmBuildPhase {
775 CargoMetadata,
777 CargoIdentity,
779 RustcIdentity,
781 CargoBuild,
783}
784
785#[non_exhaustive]
787#[derive(Debug)]
788pub enum WasmBuildError {
789 InvalidSpec {
791 message: String,
793 },
794 Io {
796 operation: &'static str,
798 path: PathBuf,
800 source: io::Error,
802 },
803 CommandSpawn {
805 phase: WasmBuildPhase,
807 program: OsString,
809 source: io::Error,
811 },
812 CommandFailed {
816 phase: WasmBuildPhase,
818 status: ExitStatus,
820 stdout: String,
822 stderr: String,
824 },
825 InvalidMetadata {
827 message: String,
829 },
830 InvalidCargoConfiguration {
832 path: PathBuf,
834 message: String,
836 },
837 MissingArtifacts {
839 paths: Vec<PathBuf>,
841 },
842 InputsChangedDuringAcquisition {
844 before: InputDigest,
846 after: InputDigest,
848 },
849 PreparedInputSnapshotInvalidated,
851 FailedBuildCleanup {
853 build_error: Box<Self>,
855 path: PathBuf,
857 source: io::Error,
859 },
860}
861
862impl WasmBuildSpec {
863 #[must_use]
883 pub fn new(
884 workspace_root: &Path,
885 target_dir: &Path,
886 packages: &[&str],
887 profile_target_dir: &str,
888 ) -> Self {
889 let mut packages = packages
890 .iter()
891 .map(|package| (*package).to_owned())
892 .collect::<Vec<_>>();
893 packages.sort();
894 packages.dedup();
895 Self {
896 workspace_root: workspace_root.to_owned(),
897 target_dir: target_dir.to_owned(),
898 packages,
899 example: None,
900 profile_target_dir: profile_target_dir.to_owned(),
901 cargo_profile_args: Vec::new(),
902 extra_env: BTreeMap::new(),
903 inherited_env: BTreeSet::new(),
904 additional_inputs: Vec::new(),
905 target: DEFAULT_TARGET.to_owned(),
906 cargo_program: std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()),
907 rustc_program: std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()),
908 cache_mode: WasmBuildCacheMode::SharedIncremental {
909 target_dir: PathBuf::from("target/ic-testkit-incremental"),
910 },
911 prune_policy: None,
912 prune_interval: None,
913 shared_incremental_maintenance_config: None,
914 }
915 }
916
917 #[must_use]
923 pub fn with_example(mut self, name: impl Into<String>) -> Self {
924 self.example = Some(name.into());
925 self
926 }
927
928 #[must_use]
930 pub fn example(&self) -> Option<&str> {
931 self.example.as_deref()
932 }
933
934 pub fn cargo_build_command(&self) -> Result<Command, WasmBuildError> {
946 validate_spec(self)?;
947 cargo_build_command(self, &self.target_dir)
948 }
949
950 #[must_use]
964 pub fn with_cargo_profile_args<I, S>(mut self, arguments: I) -> Self
965 where
966 I: IntoIterator<Item = S>,
967 S: AsRef<OsStr>,
968 {
969 self.cargo_profile_args = arguments
970 .into_iter()
971 .map(|argument| argument.as_ref().to_owned())
972 .collect();
973 self
974 }
975
976 #[must_use]
981 pub fn with_extra_env<I, K, V>(mut self, environment: I) -> Self
982 where
983 I: IntoIterator<Item = (K, V)>,
984 K: Into<OsString>,
985 V: Into<OsString>,
986 {
987 self.extra_env = environment
988 .into_iter()
989 .map(|(key, value)| (key.into(), value.into()))
990 .collect();
991 self
992 }
993
994 #[must_use]
999 pub fn with_inherited_env<I, S>(mut self, names: I) -> Self
1000 where
1001 I: IntoIterator<Item = S>,
1002 S: Into<OsString>,
1003 {
1004 self.inherited_env.extend(names.into_iter().map(Into::into));
1005 self
1006 }
1007
1008 #[must_use]
1013 pub fn with_additional_inputs<I, P>(mut self, paths: I) -> Self
1014 where
1015 I: IntoIterator<Item = P>,
1016 P: Into<PathBuf>,
1017 {
1018 self.additional_inputs
1019 .extend(paths.into_iter().map(Into::into));
1020 self
1021 }
1022
1023 #[must_use]
1025 pub fn with_target(mut self, target: &str) -> Self {
1026 target.clone_into(&mut self.target);
1027 self
1028 }
1029
1030 #[must_use]
1032 pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
1033 self.cargo_program = program.into();
1034 self
1035 }
1036
1037 #[must_use]
1045 pub fn with_rustc_program(mut self, program: impl Into<OsString>) -> Self {
1046 self.rustc_program = program.into();
1047 self
1048 }
1049
1050 #[must_use]
1056 pub fn with_shared_incremental_target(mut self, target_dir: impl Into<PathBuf>) -> Self {
1057 self.cache_mode = WasmBuildCacheMode::SharedIncremental {
1058 target_dir: target_dir.into(),
1059 };
1060 self
1061 }
1062
1063 #[must_use]
1068 pub fn with_isolated_builds(mut self) -> Self {
1069 self.cache_mode = WasmBuildCacheMode::Isolated;
1070 self
1071 }
1072
1073 #[must_use]
1084 pub const fn with_shared_incremental_target_maintenance_at_most_every(
1085 mut self,
1086 policy: SharedIncrementalTargetPrunePolicy,
1087 minimum_interval: Duration,
1088 ) -> Self {
1089 self.shared_incremental_maintenance_config = Some(
1090 SharedIncrementalTargetMaintenanceConfig::new(policy, minimum_interval),
1091 );
1092 self
1093 }
1094
1095 #[must_use]
1101 pub const fn with_shared_incremental_target_maintenance(
1102 mut self,
1103 config: SharedIncrementalTargetMaintenanceConfig,
1104 ) -> Self {
1105 self.shared_incremental_maintenance_config = Some(config);
1106 self
1107 }
1108
1109 #[must_use]
1115 pub const fn with_prune_policy(mut self, policy: ArtifactCachePrunePolicy) -> Self {
1116 self.prune_policy = Some(policy);
1117 self.prune_interval = None;
1118 self
1119 }
1120
1121 #[must_use]
1128 pub const fn with_prune_policy_at_most_every(
1129 mut self,
1130 policy: ArtifactCachePrunePolicy,
1131 minimum_interval: Duration,
1132 ) -> Self {
1133 self.prune_policy = Some(policy);
1134 self.prune_interval = Some(minimum_interval);
1135 self
1136 }
1137
1138 #[must_use]
1140 pub fn workspace_root(&self) -> &Path {
1141 &self.workspace_root
1142 }
1143
1144 #[must_use]
1146 pub fn target_dir(&self) -> &Path {
1147 &self.target_dir
1148 }
1149
1150 #[must_use]
1152 pub fn packages(&self) -> &[String] {
1153 &self.packages
1154 }
1155
1156 #[must_use]
1158 pub const fn cache_mode(&self) -> &WasmBuildCacheMode {
1159 &self.cache_mode
1160 }
1161
1162 #[must_use]
1164 pub const fn prune_policy(&self) -> Option<ArtifactCachePrunePolicy> {
1165 self.prune_policy
1166 }
1167
1168 #[must_use]
1170 pub const fn prune_interval(&self) -> Option<Duration> {
1171 self.prune_interval
1172 }
1173
1174 #[must_use]
1176 pub const fn shared_incremental_target_maintenance(
1177 &self,
1178 ) -> Option<SharedIncrementalTargetMaintenanceConfig> {
1179 self.shared_incremental_maintenance_config
1180 }
1181}
1182
1183impl WasmBuildOutcome {
1184 #[must_use]
1186 pub const fn record(&self) -> &WasmBuildRecord {
1187 match self {
1188 Self::Built(record) | Self::Reused(record) => record,
1189 }
1190 }
1191
1192 #[must_use]
1194 pub const fn is_reused(&self) -> bool {
1195 matches!(self, Self::Reused(_))
1196 }
1197}
1198
1199impl WasmBuildRecord {
1200 #[must_use]
1202 pub const fn fingerprint(&self) -> InputDigest {
1203 self.fingerprint
1204 }
1205
1206 #[must_use]
1208 pub const fn input_digest(&self) -> InputDigest {
1209 self.input_digest
1210 }
1211
1212 #[must_use]
1219 pub fn exact_cache_path(&self) -> &Path {
1220 self.retention.path()
1221 }
1222
1223 #[must_use]
1228 pub fn artifacts(&self) -> &[PathBuf] {
1229 &self.artifacts
1230 }
1231
1232 #[must_use]
1234 pub const fn timings(&self) -> WasmBuildTimings {
1235 self.timings
1236 }
1237
1238 #[must_use]
1240 pub const fn maintenance(&self) -> Option<&ArtifactCacheMaintenance> {
1241 self.maintenance.as_ref()
1242 }
1243
1244 #[must_use]
1246 pub const fn shared_incremental_maintenance(
1247 &self,
1248 ) -> Option<&SharedIncrementalTargetMaintenanceOutcome> {
1249 self.shared_incremental_maintenance.as_ref()
1250 }
1251}
1252
1253impl WasmBuildTimings {
1254 #[must_use]
1256 pub const fn lock_wait(self) -> Duration {
1257 self.lock_wait
1258 }
1259
1260 #[must_use]
1262 pub const fn shared_incremental_lock_wait(self) -> Option<Duration> {
1263 self.shared_incremental_lock_wait
1264 }
1265
1266 #[must_use]
1268 pub const fn input_resolution(self) -> WasmInputResolutionTimings {
1269 self.input_resolution
1270 }
1271
1272 #[must_use]
1274 pub const fn cargo_build(self) -> Option<Duration> {
1275 self.cargo_build
1276 }
1277
1278 #[must_use]
1280 pub const fn cache_maintenance(self) -> Option<Duration> {
1281 self.cache_maintenance
1282 }
1283
1284 #[must_use]
1286 pub const fn total(self) -> Duration {
1287 self.total
1288 }
1289
1290 pub(super) fn saturating_add(self, other: Self) -> Self {
1291 let mut input_resolution = self.input_resolution;
1292 input_resolution.include(other.input_resolution);
1293 Self {
1294 lock_wait: self.lock_wait.saturating_add(other.lock_wait),
1295 shared_incremental_lock_wait: [
1296 self.shared_incremental_lock_wait,
1297 other.shared_incremental_lock_wait,
1298 ]
1299 .into_iter()
1300 .flatten()
1301 .reduce(Duration::saturating_add),
1302 input_resolution,
1303 cargo_build: [self.cargo_build, other.cargo_build]
1304 .into_iter()
1305 .flatten()
1306 .reduce(Duration::saturating_add),
1307 cache_maintenance: [self.cache_maintenance, other.cache_maintenance]
1308 .into_iter()
1309 .flatten()
1310 .reduce(Duration::saturating_add),
1311 total: self.total.saturating_add(other.total),
1312 }
1313 }
1314}
1315
1316impl WasmInputResolutionTimings {
1317 #[must_use]
1319 pub const fn tool_identity(self) -> Duration {
1320 self.tool_identity
1321 }
1322
1323 #[must_use]
1325 pub const fn cargo_metadata(self) -> Duration {
1326 self.cargo_metadata
1327 }
1328
1329 #[must_use]
1331 pub const fn input_discovery(self) -> Duration {
1332 self.input_discovery
1333 }
1334
1335 #[must_use]
1337 pub const fn content_hashing(self) -> Duration {
1338 self.content_hashing
1339 }
1340
1341 #[must_use]
1343 pub const fn total(self) -> Duration {
1344 self.total
1345 }
1346
1347 const fn include(&mut self, other: Self) {
1348 self.tool_identity = self.tool_identity.saturating_add(other.tool_identity);
1349 self.cargo_metadata = self.cargo_metadata.saturating_add(other.cargo_metadata);
1350 self.input_discovery = self.input_discovery.saturating_add(other.input_discovery);
1351 self.content_hashing = self.content_hashing.saturating_add(other.content_hashing);
1352 self.total = self.total.saturating_add(other.total);
1353 }
1354}
1355
1356impl WasmBuildFailureDetails {
1357 pub(super) fn specification(total: Duration) -> Self {
1358 Self {
1359 phase: WasmBuildFailurePhase::Specification,
1360 timings: WasmBuildFailureTimings {
1361 total,
1362 ..WasmBuildFailureTimings::default()
1363 },
1364 }
1365 }
1366
1367 #[must_use]
1369 pub const fn phase(self) -> WasmBuildFailurePhase {
1370 self.phase
1371 }
1372
1373 #[must_use]
1375 pub const fn timings(self) -> WasmBuildFailureTimings {
1376 self.timings
1377 }
1378}
1379
1380impl WasmBuildFailureTimings {
1381 #[must_use]
1383 pub const fn exact_cache_coordination(self) -> Duration {
1384 self.exact_cache_coordination
1385 }
1386
1387 #[must_use]
1389 pub const fn shared_target_coordination(self) -> Option<Duration> {
1390 self.shared_target_coordination
1391 }
1392
1393 #[must_use]
1395 pub const fn input_resolution(self) -> WasmInputResolutionTimings {
1396 self.input_resolution
1397 }
1398
1399 #[must_use]
1401 pub const fn shared_target_maintenance(self) -> Option<Duration> {
1402 self.shared_target_maintenance
1403 }
1404
1405 #[must_use]
1407 pub const fn cargo_build(self) -> Option<Duration> {
1408 self.cargo_build
1409 }
1410
1411 #[must_use]
1413 pub const fn artifact_publication(self) -> Option<Duration> {
1414 self.artifact_publication
1415 }
1416
1417 #[must_use]
1419 pub const fn exact_cache_maintenance(self) -> Option<Duration> {
1420 self.exact_cache_maintenance
1421 }
1422
1423 #[must_use]
1425 pub const fn cleanup(self) -> Option<Duration> {
1426 self.cleanup
1427 }
1428
1429 #[must_use]
1431 pub const fn total(self) -> Duration {
1432 self.total
1433 }
1434}
1435
1436impl CargoBuildInput {
1437 #[must_use]
1439 pub fn label(&self) -> &Path {
1440 &self.label
1441 }
1442
1443 #[must_use]
1448 pub fn path(&self) -> &Path {
1449 &self.path
1450 }
1451}
1452
1453pub struct CargoInputProbePolicy<'a> {
1460 pub deadline: Option<Instant>,
1463 pub cleanup: CleanupPolicy,
1465 pub cancelled: &'a mut (dyn FnMut() -> bool + Send),
1468}
1469
1470impl ResolvedCargoBuildInputs {
1471 pub fn artifact_paths(&self) -> Result<&[PathBuf], WasmBuildError> {
1481 if let Some(message) = &self.wasm_artifact_error {
1482 return Err(WasmBuildError::InvalidSpec {
1483 message: message.clone(),
1484 });
1485 }
1486 Ok(&self.artifact_paths)
1487 }
1488
1489 #[must_use]
1491 pub const fn fingerprint(&self) -> InputDigest {
1492 self.fingerprint
1493 }
1494
1495 #[must_use]
1500 pub const fn input_digest(&self) -> InputDigest {
1501 self.input_digest
1502 }
1503
1504 #[must_use]
1509 pub const fn validation_digest(&self) -> InputDigest {
1510 self.validation_digest
1511 }
1512
1513 #[must_use]
1515 pub fn inputs(&self) -> &[CargoBuildInput] {
1516 &self.inputs
1517 }
1518
1519 #[must_use]
1524 pub fn exclusions(&self) -> &[PathBuf] {
1525 &self.exclusions
1526 }
1527
1528 #[must_use]
1530 pub const fn timings(&self) -> WasmInputResolutionTimings {
1531 self.timings
1532 }
1533
1534 pub fn is_current(&self, spec: &WasmBuildSpec) -> Result<bool, WasmBuildError> {
1539 resolve_cargo_build_inputs(spec).map(|current| current.fingerprint == self.fingerprint)
1540 }
1541
1542 pub fn is_current_with_probe_policy(
1550 &self,
1551 spec: &WasmBuildSpec,
1552 policy: &mut CargoInputProbePolicy<'_>,
1553 ) -> Result<bool, WasmBuildError> {
1554 resolve_cargo_build_inputs_with_probe_policy(spec, policy)
1555 .map(|current| current.fingerprint == self.fingerprint)
1556 }
1557
1558 pub fn is_content_current(&self) -> Result<bool, WasmBuildError> {
1568 self.current_validation_digest()
1569 .map(|current| current == self.validation_digest)
1570 }
1571
1572 pub(super) fn current_validation_digest(&self) -> Result<InputDigest, WasmBuildError> {
1573 digest_labeled_paths_composable(
1574 "wasm-source-inputs-v1",
1575 self.inputs
1576 .iter()
1577 .map(|input| (input.label.as_path(), input.path.as_path())),
1578 &self.exclusions,
1579 &mut LabeledPathDigestCache::default(),
1580 )
1581 .map_err(|source| WasmBuildError::Io {
1582 operation: "rehash resolved Cargo build inputs",
1583 path: self
1584 .inputs
1585 .first()
1586 .map_or_else(PathBuf::new, |input| input.path.clone()),
1587 source,
1588 })
1589 }
1590}
1591
1592impl WasmBuildSessionState {
1593 pub(super) fn new() -> Self {
1594 Self {
1595 snapshots: Vec::new(),
1596 digest_cache: LabeledPathDigestCache::default(),
1597 snapshot_reuses: 0,
1598 invalidated: false,
1599 }
1600 }
1601
1602 pub(super) const fn snapshot_count(&self) -> usize {
1603 self.snapshots.len()
1604 }
1605
1606 pub(super) const fn snapshot_reuses(&self) -> usize {
1607 self.snapshot_reuses
1608 }
1609
1610 pub(super) const fn is_invalidated(&self) -> bool {
1611 self.invalidated
1612 }
1613
1614 fn reuse(&mut self, spec: &WasmBuildSpec) -> Option<ResolvedCargoBuildInputs> {
1615 let (_, snapshot) = self
1616 .snapshots
1617 .iter()
1618 .find(|(candidate, _)| candidate == spec)?;
1619 self.snapshot_reuses = self.snapshot_reuses.saturating_add(1);
1620 Some(snapshot.clone())
1621 }
1622
1623 fn remember(&mut self, spec: &WasmBuildSpec, resolved: &ResolvedCargoBuildInputs) {
1624 if self
1625 .snapshots
1626 .iter()
1627 .any(|(candidate, _)| candidate == spec)
1628 {
1629 return;
1630 }
1631 let mut snapshot = resolved.clone();
1632 snapshot.timings = WasmInputResolutionTimings::default();
1633 self.snapshots.push((spec.clone(), snapshot));
1634 }
1635
1636 fn invalidate(&mut self) {
1637 self.snapshots.clear();
1638 self.digest_cache = LabeledPathDigestCache::default();
1639 self.invalidated = true;
1640 }
1641}
1642
1643impl WasmBuildInputSnapshotState {
1644 pub(super) fn prepare(specs: &[WasmBuildSpec]) -> Result<Self, WasmBuildError> {
1645 for spec in specs {
1646 validate_spec(spec)?;
1647 }
1648 let mut resolver = WasmBuildBatchInputResolver::new(specs, None);
1649 let mut snapshots = Vec::with_capacity(specs.len());
1650 let mut preparation_timings = WasmInputResolutionTimings::default();
1651 let mut progress = ProgressReporter::silent();
1652 for (index, spec) in specs.iter().enumerate() {
1653 let mut prepared_input = resolver.resolve(index, &mut progress)?;
1654 preparation_timings.include(prepared_input.timings);
1655 prepared_input.timings = WasmInputResolutionTimings::default();
1656 snapshots.push((spec.clone(), prepared_input));
1657 }
1658 Ok(Self {
1659 snapshots,
1660 preparation_metrics: resolver.metrics(),
1661 preparation_timings,
1662 reader_reuses: AtomicUsize::new(0),
1663 invalidation: Arc::new(RwLock::new(false)),
1664 })
1665 }
1666
1667 pub(super) fn contains(&self, spec: &WasmBuildSpec) -> bool {
1668 self.snapshots
1669 .iter()
1670 .any(|(candidate, _)| candidate == spec)
1671 }
1672
1673 fn reuse(&self, spec: &WasmBuildSpec) -> Option<ResolvedCargoBuildInputs> {
1674 let (_, snapshot) = self
1675 .snapshots
1676 .iter()
1677 .find(|(candidate, _)| candidate == spec)?;
1678 let mut current = self.reader_reuses.load(Ordering::Relaxed);
1679 loop {
1680 match self.reader_reuses.compare_exchange_weak(
1681 current,
1682 current.saturating_add(1),
1683 Ordering::Relaxed,
1684 Ordering::Relaxed,
1685 ) {
1686 Ok(_) => break,
1687 Err(observed) => current = observed,
1688 }
1689 }
1690 Some(snapshot.clone())
1691 }
1692
1693 pub(super) const fn specification_count(&self) -> usize {
1694 self.snapshots.len()
1695 }
1696
1697 pub(super) const fn preparation_metrics(&self) -> WasmBuildBatchInputMetrics {
1698 self.preparation_metrics
1699 }
1700
1701 pub(super) const fn preparation_timings(&self) -> WasmInputResolutionTimings {
1702 self.preparation_timings
1703 }
1704
1705 pub(super) fn reader_reuses(&self) -> usize {
1706 self.reader_reuses.load(Ordering::Relaxed)
1707 }
1708
1709 pub(super) fn is_invalidated(&self) -> bool {
1710 *self
1711 .invalidation
1712 .read()
1713 .unwrap_or_else(std::sync::PoisonError::into_inner)
1714 }
1715
1716 fn invalidate(&self) {
1717 *self
1718 .invalidation
1719 .write()
1720 .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
1721 }
1722
1723 fn invalidation(&self) -> Arc<RwLock<bool>> {
1724 Arc::clone(&self.invalidation)
1725 }
1726}
1727
1728impl<'a, 'session> WasmBuildBatchInputResolver<'a, 'session> {
1729 pub(super) fn new(
1730 specs: impl IntoIterator<Item = &'a WasmBuildSpec>,
1731 mut reuse: Option<WasmBuildInputReuse<'session>>,
1732 ) -> Self {
1733 let specs = specs.into_iter().collect::<Vec<_>>();
1734 let mut keys = Vec::<BatchResolutionKey>::new();
1735 let mut groups = Vec::<BatchResolutionGroup>::new();
1736 let mut group_by_index = Vec::with_capacity(specs.len());
1737 for (index, spec) in specs.iter().copied().enumerate() {
1738 let key = BatchResolutionKey::for_spec(spec);
1739 let group = keys
1740 .iter()
1741 .position(|candidate| *candidate == key)
1742 .unwrap_or_else(|| {
1743 keys.push(key);
1744 groups.push(BatchResolutionGroup {
1745 indexes: Vec::new(),
1746 });
1747 groups.len() - 1
1748 });
1749 groups[group].indexes.push(index);
1750 group_by_index.push(group);
1751 }
1752 let mut metrics = WasmBuildBatchInputMetrics::default();
1753 let resolved = specs
1754 .iter()
1755 .map(|spec| match reuse.as_mut() {
1756 Some(WasmBuildInputReuse::Session(session)) => {
1757 let reused = session.reuse(spec);
1758 if reused.is_some() {
1759 metrics.session_reuses = metrics.session_reuses.saturating_add(1);
1760 }
1761 reused.map(Ok)
1762 }
1763 Some(WasmBuildInputReuse::Snapshot(snapshot)) => {
1764 let reused = snapshot
1765 .reuse(spec)
1766 .expect("prepared input snapshot must contain every reader specification");
1767 metrics.prepared_reuses = metrics.prepared_reuses.saturating_add(1);
1768 Some(Ok(reused))
1769 }
1770 None => None,
1771 })
1772 .collect();
1773 Self {
1774 specs,
1775 groups,
1776 group_by_index,
1777 resolved,
1778 reuse,
1779 metrics,
1780 }
1781 }
1782
1783 pub(super) const fn metrics(&self) -> WasmBuildBatchInputMetrics {
1784 self.metrics
1785 }
1786
1787 pub(super) fn invalidate_source_lease(&mut self) {
1788 match self.reuse.as_mut() {
1789 Some(WasmBuildInputReuse::Session(session)) => {
1790 session.invalidate();
1791 for resolved in &mut self.resolved {
1795 *resolved = None;
1796 }
1797 self.reuse = None;
1798 }
1799 Some(WasmBuildInputReuse::Snapshot(snapshot)) => snapshot.invalidate(),
1800 None => {}
1801 }
1802 }
1803
1804 pub(super) const fn assumes_sources_immutable(&self) -> bool {
1805 self.reuse.is_some()
1806 }
1807
1808 pub(super) fn prepared_invalidation(&self) -> Option<Arc<RwLock<bool>>> {
1809 match self.reuse.as_ref() {
1810 Some(WasmBuildInputReuse::Snapshot(snapshot)) => Some(snapshot.invalidation()),
1811 _ => None,
1812 }
1813 }
1814
1815 fn resolve(
1816 &mut self,
1817 index: usize,
1818 progress: &mut ProgressReporter<'_>,
1819 ) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1820 if self.resolved[index].is_none() {
1821 self.resolve_group(index, progress)?;
1822 }
1823 self.resolved[index]
1824 .take()
1825 .expect("resolved batch input must be populated")
1826 .map_err(|(phase, error)| {
1827 progress.begin_phase(phase);
1828 error
1829 })
1830 }
1831
1832 fn resolve_group(
1833 &mut self,
1834 active_index: usize,
1835 progress: &mut ProgressReporter<'_>,
1836 ) -> Result<(), WasmBuildError> {
1837 let total_started = Instant::now();
1838 let group = self.group_by_index[active_index];
1839 let active = self.specs[active_index];
1840
1841 let (cargo_identity, rustc_identity, tool_identity) =
1842 resolve_tool_identity(active, progress)?;
1843
1844 let metadata_started = Instant::now();
1845 let metadata = progress.run_phase(WasmBuildProgressPhase::CargoMetadata, || {
1846 cargo_metadata(active)
1847 })?;
1848 let cargo_metadata = metadata_started.elapsed();
1849
1850 let (discovered, input_discovery) = self.discover_group_inputs(group, &metadata, progress);
1851
1852 let hashing_started = Instant::now();
1853 let mut batch_digest_cache = LabeledPathDigestCache::default();
1854 let digest_cache = match self.reuse.as_mut() {
1855 Some(WasmBuildInputReuse::Session(session)) => &mut session.digest_cache,
1856 _ => &mut batch_digest_cache,
1857 };
1858 let workspace_root = &active.workspace_root;
1859 let resolved_inputs = progress.run_phase(WasmBuildProgressPhase::ContentHashing, || {
1860 discovered
1861 .into_iter()
1862 .map(|(index, inputs, exclusions)| {
1863 let result = digest_resolved_local_inputs(
1864 &inputs,
1865 &exclusions,
1866 digest_cache,
1867 workspace_root,
1868 "hash batched Wasm build inputs",
1869 "hash batched semantic Wasm build inputs",
1870 )
1871 .map(|(input_digest, validation_digest)| {
1872 (
1873 inputs.validation_inputs,
1874 exclusions,
1875 input_digest,
1876 validation_digest,
1877 inputs.wasm_artifact_error,
1878 )
1879 });
1880 (index, result)
1881 })
1882 .collect::<Vec<_>>()
1883 });
1884 let content_hashing = hashing_started.elapsed();
1885 let timings = WasmInputResolutionTimings {
1886 tool_identity,
1887 cargo_metadata,
1888 input_discovery,
1889 content_hashing,
1890 total: total_started.elapsed(),
1891 };
1892 let resolved_count = resolved_inputs
1893 .iter()
1894 .filter(|(_, result)| result.is_ok())
1895 .count();
1896 self.metrics.runs += usize::from(resolved_count > 0);
1897 self.metrics.reuses += resolved_count.saturating_sub(1);
1898 let timing_index = resolved_inputs
1899 .iter()
1900 .find(|(index, result)| *index == active_index && result.is_ok())
1901 .or_else(|| resolved_inputs.iter().find(|(_, result)| result.is_ok()))
1902 .map(|(index, _)| *index);
1903 for (index, result) in resolved_inputs {
1904 let (inputs, exclusions, input_digest, validation_digest, wasm_artifact_error) =
1905 match result {
1906 Ok(resolved) => resolved,
1907 Err(error) => {
1908 self.resolved[index] =
1909 Some(Err((WasmBuildFailurePhase::ContentHashing, error)));
1910 continue;
1911 }
1912 };
1913 let spec = self.specs[index];
1914 let resolved = ResolvedCargoBuildInputs {
1915 fingerprint: finish_build_fingerprint(
1916 spec,
1917 &cargo_identity,
1918 &rustc_identity,
1919 input_digest,
1920 ),
1921 input_digest,
1922 validation_digest,
1923 inputs: cargo_build_inputs(inputs),
1924 exclusions: exclusions.into(),
1925 wasm_artifact_error,
1926 artifact_paths: expected_artifacts(spec, &spec.target_dir).into(),
1927 timings: if Some(index) == timing_index {
1928 timings
1929 } else {
1930 WasmInputResolutionTimings::default()
1931 },
1932 };
1933 if let Some(WasmBuildInputReuse::Session(session)) = self.reuse.as_mut() {
1934 session.remember(spec, &resolved);
1935 }
1936 self.resolved[index] = Some(Ok(resolved));
1937 }
1938 Ok(())
1939 }
1940
1941 fn discover_group_inputs(
1942 &mut self,
1943 group: usize,
1944 metadata: &Value,
1945 progress: &mut ProgressReporter<'_>,
1946 ) -> (Vec<(usize, ResolvedLocalInputs, Vec<PathBuf>)>, Duration) {
1947 let started = Instant::now();
1948 let pending = self.groups[group].indexes.iter().copied().filter(|index| {
1949 self.resolved[*index].is_none() && validate_spec(self.specs[*index]).is_ok()
1950 });
1951 let results = progress.run_phase(WasmBuildProgressPhase::InputDiscovery, || {
1952 let parsed = ParsedCargoMetadata::parse(metadata);
1953 pending
1954 .map(|index| {
1955 let spec = self.specs[index];
1956 let result = (|| {
1957 let parsed = parsed
1958 .as_ref()
1959 .map_err(|message| invalid_metadata(message))?;
1960 resolve_local_inputs(spec, parsed)
1961 })();
1962 (index, result)
1963 })
1964 .collect::<Vec<_>>()
1965 });
1966 let mut discovered = Vec::new();
1967 for (index, result) in results {
1968 match result {
1969 Ok((inputs, exclusions)) => discovered.push((index, inputs, exclusions)),
1970 Err(error) => {
1971 self.resolved[index] =
1972 Some(Err((WasmBuildFailurePhase::InputDiscovery, error)));
1973 }
1974 }
1975 }
1976 (discovered, started.elapsed())
1977 }
1978}
1979
1980fn resolve_tool_identity(
1981 spec: &WasmBuildSpec,
1982 progress: &mut ProgressReporter<'_>,
1983) -> Result<(Vec<u8>, Vec<u8>, Duration), WasmBuildError> {
1984 resolve_tool_identity_with_policy(spec, progress, &mut None)
1985}
1986
1987fn resolve_tool_identity_with_policy(
1988 spec: &WasmBuildSpec,
1989 progress: &mut ProgressReporter<'_>,
1990 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
1991) -> Result<(Vec<u8>, Vec<u8>, Duration), WasmBuildError> {
1992 let started = Instant::now();
1993 let cargo_identity = progress.run_phase(WasmBuildProgressPhase::CargoIdentity, || {
1994 command_identity_with_policy(
1995 spec,
1996 WasmBuildPhase::CargoIdentity,
1997 &spec.cargo_program,
1998 &["--version", "--verbose"],
1999 policy,
2000 )
2001 })?;
2002 let rustc_program = spec
2003 .extra_env
2004 .get(OsStr::new("RUSTC"))
2005 .unwrap_or(&spec.rustc_program);
2006 let rustc_identity = progress.run_phase(WasmBuildProgressPhase::RustcIdentity, || {
2007 command_identity_with_policy(
2008 spec,
2009 WasmBuildPhase::RustcIdentity,
2010 rustc_program,
2011 &["-vV"],
2012 policy,
2013 )
2014 })?;
2015 Ok((cargo_identity, rustc_identity, started.elapsed()))
2016}
2017
2018impl<'a> BatchResolutionKey<'a> {
2019 fn for_spec(spec: &'a WasmBuildSpec) -> Self {
2020 Self {
2021 workspace_root: &spec.workspace_root,
2022 cargo_program: &spec.cargo_program,
2023 rustc_program: spec
2024 .extra_env
2025 .get(OsStr::new("RUSTC"))
2026 .unwrap_or(&spec.rustc_program),
2027 metadata_arguments: metadata_arguments(&spec.cargo_profile_args),
2028 environment: effective_environment(spec),
2029 }
2030 }
2031}
2032
2033impl SharedIncrementalTargetInspection {
2034 #[must_use]
2036 pub fn target_dir(&self) -> &Path {
2037 &self.target_dir
2038 }
2039
2040 #[must_use]
2042 pub const fn logical_size_bytes(&self) -> u64 {
2043 self.logical_size_bytes
2044 }
2045
2046 #[must_use]
2048 pub const fn last_used(&self) -> SystemTime {
2049 self.last_used
2050 }
2051
2052 #[must_use]
2054 pub const fn lock_wait(&self) -> Duration {
2055 self.lock_wait
2056 }
2057}
2058
2059impl SharedIncrementalTargetPrunePolicy {
2060 #[must_use]
2062 pub const fn new() -> Self {
2063 Self {
2064 max_age: None,
2065 max_size_bytes: None,
2066 }
2067 }
2068
2069 #[must_use]
2071 pub const fn with_max_age(mut self, max_age: Duration) -> Self {
2072 self.max_age = Some(max_age);
2073 self
2074 }
2075
2076 #[must_use]
2078 pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
2079 self.max_size_bytes = Some(bytes);
2080 self
2081 }
2082
2083 #[must_use]
2085 pub const fn max_age(self) -> Option<Duration> {
2086 self.max_age
2087 }
2088
2089 #[must_use]
2091 pub const fn max_size_bytes(self) -> Option<u64> {
2092 self.max_size_bytes
2093 }
2094
2095 fn maintenance_identity(self) -> String {
2096 format!(
2097 "age={:?};size={:?}",
2098 self.max_age.map(|duration| duration.as_nanos()),
2099 self.max_size_bytes
2100 )
2101 }
2102}
2103
2104impl SharedIncrementalTargetMaintenanceConfig {
2105 #[must_use]
2107 pub const fn new(
2108 policy: SharedIncrementalTargetPrunePolicy,
2109 minimum_interval: Duration,
2110 ) -> Self {
2111 Self {
2112 policy,
2113 minimum_interval,
2114 failure_mode: SharedIncrementalTargetMaintenanceFailureMode::Strict,
2115 }
2116 }
2117
2118 #[must_use]
2120 pub const fn with_failure_mode(
2121 mut self,
2122 failure_mode: SharedIncrementalTargetMaintenanceFailureMode,
2123 ) -> Self {
2124 self.failure_mode = failure_mode;
2125 self
2126 }
2127
2128 #[must_use]
2130 pub const fn policy(self) -> SharedIncrementalTargetPrunePolicy {
2131 self.policy
2132 }
2133
2134 #[must_use]
2136 pub const fn minimum_interval(self) -> Duration {
2137 self.minimum_interval
2138 }
2139
2140 #[must_use]
2142 pub const fn failure_mode(self) -> SharedIncrementalTargetMaintenanceFailureMode {
2143 self.failure_mode
2144 }
2145}
2146
2147impl SharedIncrementalTargetMaintenance {
2148 #[must_use]
2150 pub fn target_dir(&self) -> &Path {
2151 &self.target_dir
2152 }
2153
2154 #[must_use]
2156 pub const fn logical_size_bytes_before(&self) -> u64 {
2157 self.logical_size_bytes_before
2158 }
2159
2160 #[must_use]
2162 pub const fn logical_size_bytes_after(&self) -> u64 {
2163 self.logical_size_bytes_after
2164 }
2165
2166 #[must_use]
2168 pub const fn last_used_before(&self) -> SystemTime {
2169 self.last_used_before
2170 }
2171
2172 #[must_use]
2174 pub const fn was_cleared(&self) -> bool {
2175 self.cleared
2176 }
2177
2178 #[must_use]
2180 pub const fn lock_wait(&self) -> Duration {
2181 self.lock_wait
2182 }
2183
2184 #[must_use]
2186 pub const fn maintenance(&self) -> Duration {
2187 self.maintenance
2188 }
2189}
2190
2191impl std::fmt::Display for SharedIncrementalTargetMaintenance {
2192 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2193 write!(
2194 formatter,
2195 "target={} action={} bytes={}=>{} lock={:?} maintenance={:?}",
2196 self.target_dir.display(),
2197 if self.cleared { "cleared" } else { "retained" },
2198 self.logical_size_bytes_before,
2199 self.logical_size_bytes_after,
2200 self.lock_wait,
2201 self.maintenance,
2202 )
2203 }
2204}
2205
2206impl SharedIncrementalTargetMaintenanceOutcome {
2207 #[must_use]
2209 pub fn target_dir(&self) -> &Path {
2210 match self {
2211 Self::Missing { target_dir }
2212 | Self::Skipped { target_dir, .. }
2213 | Self::Failed { target_dir, .. } => target_dir,
2214 Self::Performed { maintenance, .. } => maintenance.target_dir(),
2215 }
2216 }
2217
2218 #[must_use]
2220 pub const fn maintenance(&self) -> Option<&SharedIncrementalTargetMaintenance> {
2221 match self {
2222 Self::Performed { maintenance, .. } => Some(maintenance),
2223 Self::Missing { .. } | Self::Skipped { .. } | Self::Failed { .. } => None,
2224 }
2225 }
2226
2227 #[must_use]
2229 pub const fn was_performed(&self) -> bool {
2230 matches!(self, Self::Performed { .. })
2231 }
2232
2233 #[must_use]
2235 pub const fn lock_wait(&self) -> Option<Duration> {
2236 match self {
2237 Self::Missing { .. } => None,
2238 Self::Skipped { lock_wait, .. } | Self::Failed { lock_wait, .. } => Some(*lock_wait),
2239 Self::Performed { maintenance, .. } => Some(maintenance.lock_wait()),
2240 }
2241 }
2242
2243 #[must_use]
2245 pub const fn schedule_check(&self) -> Option<Duration> {
2246 match self {
2247 Self::Missing { .. } | Self::Failed { .. } => None,
2248 Self::Skipped { schedule_check, .. } | Self::Performed { schedule_check, .. } => {
2249 Some(*schedule_check)
2250 }
2251 }
2252 }
2253
2254 #[must_use]
2256 pub fn failure_message(&self) -> Option<&str> {
2257 match self {
2258 Self::Failed { message, .. } => Some(message),
2259 Self::Missing { .. } | Self::Skipped { .. } | Self::Performed { .. } => None,
2260 }
2261 }
2262}
2263
2264impl std::fmt::Display for SharedIncrementalTargetMaintenanceOutcome {
2265 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2266 match self {
2267 Self::Missing { target_dir } => {
2268 write!(formatter, "target={} action=missing", target_dir.display())
2269 }
2270 Self::Skipped {
2271 target_dir,
2272 lock_wait,
2273 schedule_check,
2274 } => write!(
2275 formatter,
2276 "target={} action=skipped lock={lock_wait:?} schedule={schedule_check:?}",
2277 target_dir.display(),
2278 ),
2279 Self::Performed {
2280 maintenance,
2281 schedule_check,
2282 } => write!(formatter, "{maintenance} schedule={schedule_check:?}"),
2283 Self::Failed {
2284 target_dir,
2285 lock_wait,
2286 message,
2287 } => write!(
2288 formatter,
2289 "target={} action=failed lock={lock_wait:?} error={message}",
2290 target_dir.display(),
2291 ),
2292 }
2293 }
2294}
2295
2296impl std::fmt::Display for WasmBuildTimings {
2297 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2298 write!(
2299 formatter,
2300 "total={:?} lock={:?} shared_lock={:?} inputs={:?} cargo={:?} maintenance={:?}",
2301 self.total,
2302 self.lock_wait,
2303 self.shared_incremental_lock_wait,
2304 self.input_resolution.total,
2305 self.cargo_build,
2306 self.cache_maintenance,
2307 )
2308 }
2309}
2310
2311impl std::fmt::Display for WasmBuildOutcome {
2312 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2313 let state = if self.is_reused() { "reused" } else { "built" };
2314 write!(
2315 formatter,
2316 "{state} fingerprint={} artifacts={} {}",
2317 self.record().fingerprint,
2318 self.record().artifacts.len(),
2319 self.record().timings,
2320 )?;
2321 if let Some(maintenance) = self.record().shared_incremental_maintenance() {
2322 write!(formatter, " shared_maintenance=({maintenance})")?;
2323 }
2324 Ok(())
2325 }
2326}
2327
2328pub fn resolve_cargo_build_inputs(
2342 spec: &WasmBuildSpec,
2343) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2344 validate_spec(spec)?;
2345 build_fingerprint(spec)
2346}
2347
2348pub fn resolve_cargo_build_inputs_with_probe_policy(
2360 spec: &WasmBuildSpec,
2361 policy: &mut CargoInputProbePolicy<'_>,
2362) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2363 validate_spec(spec)?;
2364 build_fingerprint_with_policy(spec, &mut ProgressReporter::silent(), &mut Some(policy))
2365}
2366
2367pub fn inspect_shared_incremental_target(
2375 spec: &WasmBuildSpec,
2376) -> Result<Option<SharedIncrementalTargetInspection>, WasmBuildError> {
2377 if !shared_incremental_target_exists(spec, "inspect shared incremental Cargo target")? {
2378 return Ok(None);
2379 }
2380
2381 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2382 let logical_size_bytes =
2383 directory_logical_size(&canonical).map_err(|source| WasmBuildError::Io {
2384 operation: "measure shared incremental Cargo target",
2385 path: canonical.clone(),
2386 source,
2387 })?;
2388 let last_used = cache_entry_last_used(&canonical).map_err(|source| WasmBuildError::Io {
2389 operation: "read shared incremental Cargo target use time",
2390 path: canonical.clone(),
2391 source,
2392 })?;
2393 Ok(Some(SharedIncrementalTargetInspection {
2394 target_dir: canonical,
2395 logical_size_bytes,
2396 last_used,
2397 lock_wait,
2398 }))
2399}
2400
2401pub fn maintain_shared_incremental_target(
2419 spec: &WasmBuildSpec,
2420 policy: SharedIncrementalTargetPrunePolicy,
2421) -> Result<Option<SharedIncrementalTargetMaintenance>, WasmBuildError> {
2422 if !shared_incremental_target_exists(
2423 spec,
2424 "inspect shared incremental Cargo target before maintenance",
2425 )? {
2426 return Ok(None);
2427 }
2428
2429 let _ = resolve_cargo_build_inputs(spec)?;
2433 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2434 maintain_shared_incremental_target_locked(&canonical, policy, lock_wait).map(Some)
2435}
2436
2437pub fn maintain_shared_incremental_target_at_most_every(
2455 spec: &WasmBuildSpec,
2456 policy: SharedIncrementalTargetPrunePolicy,
2457 minimum_interval: Duration,
2458) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2459 let target_dir =
2460 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
2461 message: "shared incremental target is not configured".to_owned(),
2462 })?;
2463 if !shared_incremental_target_exists(
2464 spec,
2465 "inspect shared incremental Cargo target before scheduled maintenance",
2466 )? {
2467 return Ok(SharedIncrementalTargetMaintenanceOutcome::Missing { target_dir });
2468 }
2469
2470 let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
2471 let schedule = schedule_shared_incremental_target_maintenance(
2472 &canonical,
2473 policy,
2474 minimum_interval,
2475 lock_wait,
2476 )?;
2477 let schedule = match schedule {
2478 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => return Ok(outcome),
2479 SharedIncrementalTargetMaintenanceSchedule::Due(due) => due,
2480 };
2481
2482 let _ = resolve_cargo_build_inputs(spec)?;
2485 perform_due_shared_incremental_target_maintenance(&canonical, policy, lock_wait, schedule)
2486}
2487
2488enum SharedIncrementalTargetMaintenanceSchedule {
2489 Skipped(SharedIncrementalTargetMaintenanceOutcome),
2490 Due(DueSharedIncrementalTargetMaintenance),
2491}
2492
2493struct DueSharedIncrementalTargetMaintenance {
2494 schedule_root: PathBuf,
2495 maintenance_identity: String,
2496 schedule_check: Duration,
2497}
2498
2499fn schedule_shared_incremental_target_maintenance(
2500 canonical: &Path,
2501 policy: SharedIncrementalTargetPrunePolicy,
2502 minimum_interval: Duration,
2503 lock_wait: Duration,
2504) -> Result<SharedIncrementalTargetMaintenanceSchedule, WasmBuildError> {
2505 let schedule_root = canonical.join(".ic-testkit");
2506 let maintenance_identity = policy.maintenance_identity();
2507 let schedule_started = Instant::now();
2508 let due = cache_maintenance_due(
2509 &schedule_root,
2510 Some(minimum_interval),
2511 &maintenance_identity,
2512 )
2513 .map_err(wasm_cache_fs_error)?;
2514 let schedule_check = schedule_started.elapsed();
2515 if !due {
2516 return Ok(SharedIncrementalTargetMaintenanceSchedule::Skipped(
2517 SharedIncrementalTargetMaintenanceOutcome::Skipped {
2518 target_dir: canonical.to_owned(),
2519 lock_wait,
2520 schedule_check,
2521 },
2522 ));
2523 }
2524 Ok(SharedIncrementalTargetMaintenanceSchedule::Due(
2525 DueSharedIncrementalTargetMaintenance {
2526 schedule_root,
2527 maintenance_identity,
2528 schedule_check,
2529 },
2530 ))
2531}
2532
2533fn perform_due_shared_incremental_target_maintenance(
2534 canonical: &Path,
2535 policy: SharedIncrementalTargetPrunePolicy,
2536 lock_wait: Duration,
2537 due: DueSharedIncrementalTargetMaintenance,
2538) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2539 let DueSharedIncrementalTargetMaintenance {
2540 schedule_root,
2541 maintenance_identity,
2542 schedule_check,
2543 } = due;
2544 let maintenance = maintain_shared_incremental_target_locked(canonical, policy, lock_wait)?;
2545 record_cache_maintenance(&schedule_root, &maintenance_identity).map_err(wasm_cache_fs_error)?;
2546 Ok(SharedIncrementalTargetMaintenanceOutcome::Performed {
2547 maintenance,
2548 schedule_check,
2549 })
2550}
2551
2552fn maintain_shared_incremental_target_locked(
2553 canonical: &Path,
2554 policy: SharedIncrementalTargetPrunePolicy,
2555 lock_wait: Duration,
2556) -> Result<SharedIncrementalTargetMaintenance, WasmBuildError> {
2557 let started = Instant::now();
2558 let logical_size_bytes_before =
2559 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2560 operation: "measure shared incremental Cargo target before maintenance",
2561 path: canonical.to_owned(),
2562 source,
2563 })?;
2564 let last_used_before =
2565 cache_entry_last_used(canonical).map_err(|source| WasmBuildError::Io {
2566 operation: "read shared incremental Cargo target use time before maintenance",
2567 path: canonical.to_owned(),
2568 source,
2569 })?;
2570 let expired = policy.max_age.is_some_and(|max_age| {
2571 SystemTime::now()
2572 .duration_since(last_used_before)
2573 .is_ok_and(|age| age > max_age)
2574 });
2575 let oversized = policy
2576 .max_size_bytes
2577 .is_some_and(|max_size_bytes| logical_size_bytes_before > max_size_bytes);
2578 let cleared = expired || oversized;
2579 if cleared {
2580 clear_shared_incremental_target_contents(canonical)?;
2581 record_cache_entry_use(canonical)?;
2582 }
2583 let logical_size_bytes_after = if cleared {
2584 directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
2585 operation: "measure shared incremental Cargo target after maintenance",
2586 path: canonical.to_owned(),
2587 source,
2588 })?
2589 } else {
2590 logical_size_bytes_before
2591 };
2592 Ok(SharedIncrementalTargetMaintenance {
2593 target_dir: canonical.to_owned(),
2594 logical_size_bytes_before,
2595 logical_size_bytes_after,
2596 last_used_before,
2597 cleared,
2598 lock_wait,
2599 maintenance: started.elapsed(),
2600 })
2601}
2602
2603fn clear_shared_incremental_target_contents(target_dir: &Path) -> Result<(), WasmBuildError> {
2604 let entries = fs::read_dir(target_dir).map_err(|source| WasmBuildError::Io {
2605 operation: "read shared incremental Cargo target for maintenance",
2606 path: target_dir.to_owned(),
2607 source,
2608 })?;
2609 for entry in entries {
2610 let path = entry
2611 .map_err(|source| WasmBuildError::Io {
2612 operation: "read shared incremental Cargo target entry for maintenance",
2613 path: target_dir.to_owned(),
2614 source,
2615 })?
2616 .path();
2617 let preserved = path
2618 .file_name()
2619 .is_some_and(|name| name == ".ic-testkit" || name == "CACHEDIR.TAG");
2620 if !preserved {
2621 remove_path_if_present(&path).map_err(|source| WasmBuildError::Io {
2622 operation: "clear shared incremental Cargo target entry",
2623 path,
2624 source,
2625 })?;
2626 }
2627 }
2628 Ok(())
2629}
2630
2631pub fn build_wasm_canisters_cached(
2653 spec: &WasmBuildSpec,
2654) -> Result<WasmBuildOutcome, WasmBuildError> {
2655 build_wasm_canisters_cached_internal(spec, &mut ProgressReporter::silent(), None)
2656}
2657
2658pub(super) fn build_wasm_canisters_cached_in_batch(
2659 spec: &WasmBuildSpec,
2660 index: usize,
2661 resolver: &mut WasmBuildBatchInputResolver<'_, '_>,
2662) -> WasmBuildBatchAttempt {
2663 let started = Instant::now();
2664 let mut progress = ProgressReporter::silent();
2665 let result = build_wasm_canisters_cached_internal(spec, &mut progress, Some((resolver, index)));
2666 if result
2667 .as_ref()
2668 .is_err_and(WasmBuildError::indicates_input_change)
2669 {
2670 resolver.invalidate_source_lease();
2671 }
2672 batch_result(result, &progress, started.elapsed())
2673}
2674
2675pub fn build_wasm_canisters_cached_with_progress<F>(
2692 spec: &WasmBuildSpec,
2693 config: WasmBuildProgressConfig,
2694 mut observer: F,
2695) -> Result<WasmBuildOutcome, WasmBuildError>
2696where
2697 F: FnMut(WasmBuildProgressEvent),
2698{
2699 if config.heartbeat_interval == Some(Duration::ZERO) {
2700 return Err(WasmBuildError::InvalidSpec {
2701 message: "Wasm build progress heartbeat interval must be greater than zero".to_owned(),
2702 });
2703 }
2704 build_wasm_canisters_cached_internal(
2705 spec,
2706 &mut ProgressReporter::observed(config, &mut observer),
2707 None,
2708 )
2709}
2710
2711pub(super) fn build_wasm_canisters_cached_in_batch_with_progress<F>(
2712 spec: &WasmBuildSpec,
2713 index: usize,
2714 resolver: &mut WasmBuildBatchInputResolver<'_, '_>,
2715 config: WasmBuildProgressConfig,
2716 mut observer: F,
2717) -> WasmBuildBatchAttempt
2718where
2719 F: FnMut(WasmBuildProgressEvent),
2720{
2721 if config.heartbeat_interval == Some(Duration::ZERO) {
2722 return WasmBuildBatchAttempt {
2723 result: Err((
2724 WasmBuildError::InvalidSpec {
2725 message: "Wasm build progress heartbeat interval must be greater than zero"
2726 .to_owned(),
2727 },
2728 WasmBuildFailureDetails::specification(Duration::ZERO),
2729 )),
2730 };
2731 }
2732 let started = Instant::now();
2733 let mut progress = ProgressReporter::observed(config, &mut observer);
2734 let result = build_wasm_canisters_cached_internal(spec, &mut progress, Some((resolver, index)));
2735 if result
2736 .as_ref()
2737 .is_err_and(WasmBuildError::indicates_input_change)
2738 {
2739 resolver.invalidate_source_lease();
2740 }
2741 batch_result(result, &progress, started.elapsed())
2742}
2743
2744fn batch_result(
2745 result: Result<WasmBuildOutcome, WasmBuildError>,
2746 progress: &ProgressReporter<'_>,
2747 total: Duration,
2748) -> WasmBuildBatchAttempt {
2749 WasmBuildBatchAttempt {
2750 result: result.map_err(|error| {
2751 let details = progress.failure_details(&error, total);
2752 (error, details)
2753 }),
2754 }
2755}
2756
2757fn batch_source_assumptions(
2758 batch_resolution: Option<&(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2759) -> (bool, Option<Arc<RwLock<bool>>>) {
2760 batch_resolution.map_or((false, None), |(resolver, _)| {
2761 (
2762 resolver.assumes_sources_immutable(),
2763 resolver.prepared_invalidation(),
2764 )
2765 })
2766}
2767
2768fn build_wasm_canisters_cached_internal(
2769 spec: &WasmBuildSpec,
2770 progress: &mut ProgressReporter<'_>,
2771 mut batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2772) -> Result<WasmBuildOutcome, WasmBuildError> {
2773 let total_started = Instant::now();
2774 validate_spec(spec)?;
2775 let (assumes_sources_immutable, prepared_invalidation) =
2776 batch_source_assumptions(batch_resolution.as_ref());
2777 progress.emit(WasmBuildProgressEvent::Started);
2778 if spec.shared_incremental_maintenance_config.is_some() {
2779 let outcome = build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2780 spec,
2781 total_started,
2782 progress,
2783 batch_resolution.take(),
2784 )?;
2785 emit_finished_progress(&outcome, progress);
2786 return Ok(outcome);
2787 }
2788 let (cache_lock, first_lock_wait) =
2789 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2790 ensure_cache_directory_tag(&spec.target_dir)?;
2791
2792 let resolved = resolve_initial_inputs(spec, batch_resolution.take(), progress)?;
2793 let isolated_acquisition =
2794 WasmAcquisitionContext::isolated(prepared_invalidation.clone(), assumes_sources_immutable);
2795 if let Some(outcome) = try_reuse_wasm_artifacts(
2796 spec,
2797 &resolved,
2798 first_lock_wait,
2799 &isolated_acquisition,
2800 total_started,
2801 progress,
2802 )? {
2803 emit_finished_progress(&outcome, progress);
2804 return Ok(outcome);
2805 }
2806 progress.emit(WasmBuildProgressEvent::CacheMiss {
2807 fingerprint: resolved.fingerprint,
2808 });
2809
2810 let outcome = match &spec.cache_mode {
2811 WasmBuildCacheMode::Isolated => {
2812 let cache_entry = cache_entry_directory(spec, resolved.fingerprint);
2813 build_wasm_cache_miss(
2814 spec,
2815 &resolved,
2816 first_lock_wait,
2817 isolated_acquisition,
2818 &cache_entry,
2819 total_started,
2820 progress,
2821 )
2822 }
2823 WasmBuildCacheMode::SharedIncremental { .. } => {
2824 drop(cache_lock);
2825 build_wasm_with_shared_incremental(
2826 spec,
2827 resolved,
2828 first_lock_wait,
2829 assumes_sources_immutable,
2830 prepared_invalidation,
2831 total_started,
2832 progress,
2833 )
2834 }
2835 }?;
2836 emit_finished_progress(&outcome, progress);
2837 Ok(outcome)
2838}
2839
2840fn build_wasm_with_shared_incremental(
2841 spec: &WasmBuildSpec,
2842 resolved: ResolvedCargoBuildInputs,
2843 first_lock_wait: Duration,
2844 assumes_sources_immutable: bool,
2845 prepared_invalidation: Option<Arc<RwLock<bool>>>,
2846 total_started: Instant,
2847 progress: &mut ProgressReporter<'_>,
2848) -> Result<WasmBuildOutcome, WasmBuildError> {
2849 let (shared_lock, shared_lock_wait, shared_target) =
2850 lock_shared_incremental_target_with_progress(spec, progress)?;
2851 let (_cache_lock, second_lock_wait) =
2852 lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2853 ensure_cache_directory_tag(&spec.target_dir)?;
2854
2855 let current = if assumes_sources_immutable {
2856 resolved
2857 } else {
2858 let mut current = resolve_inputs_with_progress(spec, progress)?;
2859 current.timings.include(resolved.timings);
2860 current
2861 };
2862 let lock_wait = first_lock_wait.saturating_add(second_lock_wait);
2863 let shared_incremental = WasmAcquisitionContext::shared(
2864 shared_lock_wait,
2865 None,
2866 prepared_invalidation,
2867 assumes_sources_immutable,
2868 );
2869 if let Some(outcome) = try_reuse_wasm_artifacts(
2870 spec,
2871 ¤t,
2872 lock_wait,
2873 &shared_incremental,
2874 total_started,
2875 progress,
2876 )? {
2877 return Ok(outcome);
2878 }
2879
2880 let outcome = build_wasm_cache_miss(
2881 spec,
2882 ¤t,
2883 lock_wait,
2884 shared_incremental,
2885 &shared_target,
2886 total_started,
2887 progress,
2888 );
2889 drop(shared_lock);
2890 outcome
2891}
2892
2893fn build_wasm_canisters_cached_with_scheduled_shared_maintenance(
2894 spec: &WasmBuildSpec,
2895 total_started: Instant,
2896 progress: &mut ProgressReporter<'_>,
2897 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
2898) -> Result<WasmBuildOutcome, WasmBuildError> {
2899 let (assumes_sources_immutable, prepared_invalidation) =
2900 batch_source_assumptions(batch_resolution.as_ref());
2901 let (_shared_lock, shared_lock_wait, shared_target) =
2902 lock_shared_incremental_target_with_progress(spec, progress)?;
2903 let (_cache_lock, lock_wait) = lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
2904 ensure_cache_directory_tag(&spec.target_dir)?;
2905
2906 let resolved = resolve_initial_inputs(spec, batch_resolution, progress)?;
2909 let shared_maintenance = perform_configured_shared_incremental_target_maintenance(
2910 spec,
2911 &shared_target,
2912 shared_lock_wait,
2913 progress,
2914 )?;
2915 let shared_incremental = WasmAcquisitionContext::shared(
2916 shared_lock_wait,
2917 Some(shared_maintenance),
2918 prepared_invalidation,
2919 assumes_sources_immutable,
2920 );
2921 if let Some(outcome) = try_reuse_wasm_artifacts(
2922 spec,
2923 &resolved,
2924 lock_wait,
2925 &shared_incremental,
2926 total_started,
2927 progress,
2928 )? {
2929 return Ok(outcome);
2930 }
2931 progress.emit(WasmBuildProgressEvent::CacheMiss {
2932 fingerprint: resolved.fingerprint,
2933 });
2934 build_wasm_cache_miss(
2935 spec,
2936 &resolved,
2937 lock_wait,
2938 shared_incremental,
2939 &shared_target,
2940 total_started,
2941 progress,
2942 )
2943}
2944
2945fn perform_configured_shared_incremental_target_maintenance(
2946 spec: &WasmBuildSpec,
2947 shared_target: &Path,
2948 lock_wait: Duration,
2949 progress: &mut ProgressReporter<'_>,
2950) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2951 let config = spec
2952 .shared_incremental_maintenance_config
2953 .expect("configured shared-target maintenance must have settings");
2954 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceStarted {
2955 target_dir: shared_target.to_owned(),
2956 });
2957 let result = progress.run_phase(WasmBuildProgressPhase::SharedTargetMaintenance, || {
2958 let schedule = schedule_shared_incremental_target_maintenance(
2959 shared_target,
2960 config.policy,
2961 config.minimum_interval,
2962 lock_wait,
2963 )?;
2964 match schedule {
2965 SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => Ok(outcome),
2966 SharedIncrementalTargetMaintenanceSchedule::Due(due) => {
2967 perform_due_shared_incremental_target_maintenance(
2968 shared_target,
2969 config.policy,
2970 lock_wait,
2971 due,
2972 )
2973 }
2974 }
2975 });
2976 let outcome = integrated_shared_maintenance_result(config, shared_target, lock_wait, result)?;
2977 progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceFinished {
2978 outcome: outcome.clone(),
2979 });
2980 Ok(outcome)
2981}
2982
2983fn integrated_shared_maintenance_result(
2984 config: SharedIncrementalTargetMaintenanceConfig,
2985 shared_target: &Path,
2986 lock_wait: Duration,
2987 result: Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError>,
2988) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
2989 match result {
2990 Ok(outcome) => Ok(outcome),
2991 Err(error)
2992 if config.failure_mode == SharedIncrementalTargetMaintenanceFailureMode::BestEffort =>
2993 {
2994 Ok(SharedIncrementalTargetMaintenanceOutcome::Failed {
2995 target_dir: shared_target.to_owned(),
2996 lock_wait,
2997 message: error.to_string(),
2998 })
2999 }
3000 Err(error) => Err(error),
3001 }
3002}
3003
3004fn resolve_inputs_with_progress(
3005 spec: &WasmBuildSpec,
3006 progress: &mut ProgressReporter<'_>,
3007) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3008 let resolved = build_fingerprint_with_progress(spec, progress)?;
3009 validate_wasm_library_outputs(&resolved, progress)?;
3010 progress.emit(WasmBuildProgressEvent::InputsResolved {
3011 fingerprint: resolved.fingerprint,
3012 input_digest: resolved.input_digest,
3013 elapsed: resolved.timings.total,
3014 });
3015 Ok(resolved)
3016}
3017
3018fn resolve_initial_inputs(
3019 spec: &WasmBuildSpec,
3020 batch_resolution: Option<(&mut WasmBuildBatchInputResolver<'_, '_>, usize)>,
3021 progress: &mut ProgressReporter<'_>,
3022) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3023 let resolved = if let Some((resolver, index)) = batch_resolution {
3024 resolver.resolve(index, progress)?
3025 } else {
3026 build_fingerprint_with_progress(spec, progress)?
3027 };
3028 validate_wasm_library_outputs(&resolved, progress)?;
3029 progress.emit(WasmBuildProgressEvent::InputsResolved {
3030 fingerprint: resolved.fingerprint,
3031 input_digest: resolved.input_digest,
3032 elapsed: resolved.timings.total,
3033 });
3034 Ok(resolved)
3035}
3036
3037fn validate_wasm_library_outputs(
3038 resolved: &ResolvedCargoBuildInputs,
3039 progress: &mut ProgressReporter<'_>,
3040) -> Result<(), WasmBuildError> {
3041 if let Some(message) = &resolved.wasm_artifact_error {
3042 progress.begin_phase(WasmBuildFailurePhase::InputDiscovery);
3043 return Err(WasmBuildError::InvalidSpec {
3044 message: message.clone(),
3045 });
3046 }
3047 Ok(())
3048}
3049
3050fn emit_finished_progress(outcome: &WasmBuildOutcome, progress: &mut ProgressReporter<'_>) {
3051 let state = if outcome.is_reused() {
3052 progress.emit(WasmBuildProgressEvent::CacheHit {
3053 fingerprint: outcome.record().fingerprint,
3054 });
3055 WasmBuildProgressOutcome::Reused
3056 } else {
3057 WasmBuildProgressOutcome::Built
3058 };
3059 progress.emit(WasmBuildProgressEvent::Finished {
3060 outcome: state,
3061 fingerprint: outcome.record().fingerprint,
3062 elapsed: outcome.record().timings.total,
3063 });
3064}
3065
3066#[derive(Clone, Debug, Default)]
3067struct WasmAcquisitionContext {
3068 assumes_sources_immutable: bool,
3069 lock_wait: Option<Duration>,
3070 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
3071 prepared_invalidation: Option<Arc<RwLock<bool>>>,
3072}
3073
3074impl WasmAcquisitionContext {
3075 fn isolated(
3076 prepared_invalidation: Option<Arc<RwLock<bool>>>,
3077 assumes_sources_immutable: bool,
3078 ) -> Self {
3079 Self {
3080 assumes_sources_immutable,
3081 prepared_invalidation,
3082 ..Self::default()
3083 }
3084 }
3085
3086 const fn shared(
3087 lock_wait: Duration,
3088 maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
3089 prepared_invalidation: Option<Arc<RwLock<bool>>>,
3090 assumes_sources_immutable: bool,
3091 ) -> Self {
3092 Self {
3093 assumes_sources_immutable,
3094 lock_wait: Some(lock_wait),
3095 maintenance,
3096 prepared_invalidation,
3097 }
3098 }
3099
3100 fn lock_prepared_publication(
3101 &self,
3102 ) -> Result<Option<std::sync::RwLockReadGuard<'_, bool>>, WasmBuildError> {
3103 let guard = self.prepared_invalidation.as_deref().map(|invalidation| {
3104 invalidation
3105 .read()
3106 .unwrap_or_else(std::sync::PoisonError::into_inner)
3107 });
3108 if guard.as_deref().is_some_and(|invalidated| *invalidated) {
3109 return Err(WasmBuildError::PreparedInputSnapshotInvalidated);
3110 }
3111 Ok(guard)
3112 }
3113}
3114
3115fn try_reuse_wasm_artifacts(
3116 spec: &WasmBuildSpec,
3117 resolved: &ResolvedCargoBuildInputs,
3118 lock_wait: Duration,
3119 shared_incremental: &WasmAcquisitionContext,
3120 total_started: Instant,
3121 progress: &mut ProgressReporter<'_>,
3122) -> Result<Option<WasmBuildOutcome>, WasmBuildError> {
3123 let _publication_guard = shared_incremental.lock_prepared_publication()?;
3124 let fingerprint = resolved.fingerprint;
3125 let artifacts = expected_artifacts(spec, &spec.target_dir);
3126 let cache_entry = cache_entry_directory(spec, fingerprint);
3127 let cached_artifacts = expected_artifacts(spec, &cache_entry);
3128 let cached_info = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3129 validated_artifact_set(&cached_artifacts, fingerprint)
3130 });
3131 let artifact_info = if let Some(info) = cached_info {
3132 info
3133 } else {
3134 let public_is_current = progress
3137 .run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3138 validated_artifact_set(&artifacts, fingerprint).is_some()
3139 });
3140 if !public_is_current {
3141 return Ok(None);
3142 }
3143 reconstruct_exact_cache_entry(spec, &artifacts, &cache_entry, fingerprint, progress)?
3144 };
3145 progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3146 materialize_artifacts(
3147 &cached_artifacts,
3148 &artifacts,
3149 fingerprint,
3150 &artifact_info,
3151 true,
3152 )?;
3153 record_cache_entry_use(&cache_entry)
3154 })?;
3155 let input_resolution = validate_reused_inputs(spec, resolved, shared_incremental, progress)?;
3156 Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
3157 spec,
3158 BuildRecordInput {
3159 fingerprint,
3160 input_digest: resolved.input_digest,
3161 lock_wait,
3162 shared_incremental: shared_incremental.clone(),
3163 input_resolution,
3164 cargo_build: None,
3165 active_entry: &cache_entry,
3166 },
3167 total_started,
3168 progress,
3169 )?)))
3170}
3171
3172fn validate_reused_inputs(
3173 spec: &WasmBuildSpec,
3174 resolved: &ResolvedCargoBuildInputs,
3175 context: &WasmAcquisitionContext,
3176 progress: &mut ProgressReporter<'_>,
3177) -> Result<WasmInputResolutionTimings, WasmBuildError> {
3178 let mut timings = resolved.timings;
3179 if !context.assumes_sources_immutable {
3180 let verified = verify_resolved_inputs(spec, resolved, progress)?;
3181 timings.include(verified.timings);
3182 }
3183 Ok(timings)
3184}
3185
3186fn verify_resolved_inputs(
3187 spec: &WasmBuildSpec,
3188 resolved: &ResolvedCargoBuildInputs,
3189 progress: &mut ProgressReporter<'_>,
3190) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3191 let verified = resolve_inputs_with_progress(spec, progress)?;
3192 for (before, after) in [
3193 (resolved.validation_digest, verified.validation_digest),
3194 (resolved.fingerprint, verified.fingerprint),
3195 ] {
3196 if before != after {
3197 return Err(WasmBuildError::InputsChangedDuringAcquisition { before, after });
3198 }
3199 }
3200 Ok(verified)
3201}
3202
3203fn reconstruct_exact_cache_entry(
3204 spec: &WasmBuildSpec,
3205 artifacts: &[PathBuf],
3206 cache_entry: &Path,
3207 fingerprint: InputDigest,
3208 progress: &mut ProgressReporter<'_>,
3209) -> Result<Vec<FileDigest>, WasmBuildError> {
3210 let cached_artifacts = expected_artifacts(spec, cache_entry);
3211 progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3212 remove_unretained_entry(cache_entry).map_err(wasm_cache_fs_error)?;
3213 create_dir_all(
3214 cache_entry,
3215 "create content-addressed Cargo target directory",
3216 )
3217 })?;
3218 let incomplete = IncompleteBuildDirectory::new(cache_entry.to_owned());
3219 let result = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3220 copy_wasm_artifacts(artifacts, &cached_artifacts)?;
3221 let missing = missing_artifacts(&cached_artifacts);
3222 if !missing.is_empty() {
3223 return Err(WasmBuildError::MissingArtifacts { paths: missing });
3224 }
3225 publish_artifact_stamps(&cached_artifacts, fingerprint)
3228 });
3229 finish_fingerprint_build(result, incomplete, progress)
3230}
3231
3232fn build_wasm_cache_miss(
3233 spec: &WasmBuildSpec,
3234 resolved: &ResolvedCargoBuildInputs,
3235 lock_wait: Duration,
3236 shared_incremental: WasmAcquisitionContext,
3237 cargo_target_dir: &Path,
3238 total_started: Instant,
3239 progress: &mut ProgressReporter<'_>,
3240) -> Result<WasmBuildOutcome, WasmBuildError> {
3241 let fingerprint = resolved.fingerprint;
3242 let mut input_resolution = resolved.timings;
3243 let artifacts = expected_artifacts(spec, &spec.target_dir);
3244 let cache_entry = cache_entry_directory(spec, fingerprint);
3245 let preparation_started = Instant::now();
3246 progress.begin_phase(WasmBuildFailurePhase::ArtifactPublication);
3247 let preparation_result = (|| {
3248 remove_unretained_entry(&cache_entry).map_err(wasm_cache_fs_error)?;
3249 create_dir_all(
3250 &cache_entry,
3251 "create content-addressed Cargo target directory",
3252 )
3253 })();
3254 progress.record_phase(
3255 WasmBuildFailurePhase::ArtifactPublication,
3256 preparation_started.elapsed(),
3257 );
3258 preparation_result?;
3259 let incomplete_directory = IncompleteBuildDirectory::new(cache_entry.clone());
3260 let build_result = (|| {
3261 if matches!(
3262 spec.cache_mode,
3263 WasmBuildCacheMode::SharedIncremental { .. }
3264 ) {
3265 record_cache_entry_use(cargo_target_dir)?;
3266 }
3267 let build_started = Instant::now();
3268 progress.begin_phase(WasmBuildFailurePhase::CargoBuild);
3269 let cargo_result = run_cargo_build(spec, cargo_target_dir, progress);
3270 let cargo_build = build_started.elapsed();
3271 progress.record_phase(WasmBuildFailurePhase::CargoBuild, cargo_build);
3272 cargo_result?;
3273 let built_artifacts = expected_artifacts(spec, cargo_target_dir);
3274 let validation_started = Instant::now();
3275 progress.begin_phase(WasmBuildFailurePhase::ArtifactPublication);
3276 let missing = missing_artifacts(&built_artifacts);
3277 progress.record_phase(
3278 WasmBuildFailurePhase::ArtifactPublication,
3279 validation_started.elapsed(),
3280 );
3281 if !missing.is_empty() {
3282 return Err(WasmBuildError::MissingArtifacts { paths: missing });
3283 }
3284
3285 let verified = verify_resolved_inputs(spec, resolved, progress)?;
3286 input_resolution.include(verified.timings);
3287
3288 let publication_guard = shared_incremental.lock_prepared_publication()?;
3293
3294 let cached_artifacts = expected_artifacts(spec, &cache_entry);
3295 progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3296 if cargo_target_dir != cache_entry {
3297 copy_wasm_artifacts(&built_artifacts, &cached_artifacts)?;
3298 }
3299 let info = publish_artifact_stamps(&cached_artifacts, fingerprint)?;
3300 materialize_artifacts(&cached_artifacts, &artifacts, fingerprint, &info, false)?;
3301 record_cache_entry_use(&cache_entry)
3302 })?;
3303 drop(publication_guard);
3304
3305 Ok(WasmBuildOutcome::Built(complete_build_record(
3306 spec,
3307 BuildRecordInput {
3308 fingerprint,
3309 input_digest: resolved.input_digest,
3310 lock_wait,
3311 shared_incremental,
3312 input_resolution,
3313 cargo_build: Some(cargo_build),
3314 active_entry: &cache_entry,
3315 },
3316 total_started,
3317 progress,
3318 )?))
3319 })();
3320 finish_fingerprint_build(build_result, incomplete_directory, progress)
3321}
3322
3323pub fn prune_wasm_build_cache(
3335 target_dir: &Path,
3336 policy: ArtifactCachePrunePolicy,
3337) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3338 let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
3339 ensure_cache_directory_tag(target_dir)?;
3340
3341 prune_wasm_build_cache_locked(target_dir, policy, None)
3342}
3343
3344struct BuildRecordInput<'a> {
3345 fingerprint: InputDigest,
3346 input_digest: InputDigest,
3347 lock_wait: Duration,
3348 shared_incremental: WasmAcquisitionContext,
3349 input_resolution: WasmInputResolutionTimings,
3350 cargo_build: Option<Duration>,
3351 active_entry: &'a Path,
3352}
3353
3354fn complete_build_record(
3355 spec: &WasmBuildSpec,
3356 input: BuildRecordInput<'_>,
3357 total_started: Instant,
3358 progress: &mut ProgressReporter<'_>,
3359) -> Result<WasmBuildRecord, WasmBuildError> {
3360 let retention = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
3361 RetainedCacheEntry::acquire(input.active_entry).map_err(wasm_cache_fs_error)
3362 })?;
3363 let (maintenance, cache_maintenance) = spec.prune_policy.map_or((None, None), |policy| {
3364 progress.run_phase(WasmBuildProgressPhase::ExactCacheMaintenance, || {
3365 let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
3366 let identity = policy.maintenance_identity();
3367 perform_scheduled_cache_maintenance(&cache_root, spec.prune_interval, &identity, || {
3368 prune_wasm_build_cache_locked(&spec.target_dir, policy, Some(input.active_entry))
3369 .map_err(|error| error.to_string())
3370 })
3371 })
3372 });
3373 Ok(WasmBuildRecord {
3374 fingerprint: input.fingerprint,
3375 input_digest: input.input_digest,
3376 artifacts: expected_artifacts(spec, input.active_entry),
3377 retention,
3378 timings: WasmBuildTimings {
3379 lock_wait: input.lock_wait,
3380 shared_incremental_lock_wait: input.shared_incremental.lock_wait,
3381 input_resolution: input.input_resolution,
3382 cargo_build: input.cargo_build,
3383 cache_maintenance,
3384 total: total_started.elapsed(),
3385 },
3386 maintenance,
3387 shared_incremental_maintenance: input.shared_incremental.maintenance,
3388 })
3389}
3390
3391fn prune_wasm_build_cache_locked(
3392 target_dir: &Path,
3393 policy: ArtifactCachePrunePolicy,
3394 protected_entry: Option<&Path>,
3395) -> Result<ArtifactCachePruneReport, WasmBuildError> {
3396 let cache_root = target_dir.join(".ic-testkit/wasm-targets");
3397 prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
3398 .map_err(wasm_cache_fs_error)
3399}
3400
3401struct IncompleteBuildDirectory {
3402 path: PathBuf,
3403 armed: bool,
3404}
3405
3406impl IncompleteBuildDirectory {
3407 const fn new(path: PathBuf) -> Self {
3408 Self { path, armed: true }
3409 }
3410
3411 fn preserve(mut self) {
3412 self.armed = false;
3413 }
3414
3415 fn cleanup(mut self) -> io::Result<()> {
3416 let result = remove_path_if_present(&self.path);
3417 if result.is_ok() {
3418 self.armed = false;
3419 }
3420 result
3421 }
3422}
3423
3424impl Drop for IncompleteBuildDirectory {
3425 fn drop(&mut self) {
3426 if self.armed {
3427 let _ = remove_path_if_present(&self.path);
3428 }
3429 }
3430}
3431
3432fn finish_fingerprint_build<T>(
3433 result: Result<T, WasmBuildError>,
3434 incomplete_directory: IncompleteBuildDirectory,
3435 progress: &mut ProgressReporter<'_>,
3436) -> Result<T, WasmBuildError> {
3437 match result {
3438 Ok(outcome) => {
3439 incomplete_directory.preserve();
3440 Ok(outcome)
3441 }
3442 Err(build_error) => Err(cleanup_failed_fingerprint_build(
3443 build_error,
3444 incomplete_directory,
3445 progress,
3446 )),
3447 }
3448}
3449
3450fn cleanup_failed_fingerprint_build(
3451 build_error: WasmBuildError,
3452 incomplete_directory: IncompleteBuildDirectory,
3453 progress: &mut ProgressReporter<'_>,
3454) -> WasmBuildError {
3455 let path = incomplete_directory.path.clone();
3456 let primary_phase = progress.failure_phase;
3457 let cleanup_started = Instant::now();
3458 let cleanup = incomplete_directory.cleanup();
3459 progress.record_phase(WasmBuildFailurePhase::Cleanup, cleanup_started.elapsed());
3460 match cleanup {
3461 Ok(()) => {
3462 progress.failure_phase = primary_phase;
3463 build_error
3464 }
3465 Err(source) => WasmBuildError::FailedBuildCleanup {
3466 build_error: Box::new(build_error),
3467 path,
3468 source,
3469 },
3470 }
3471}
3472
3473fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
3474 create_dir_all(target_dir, "create Cargo target directory")?;
3475 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3476 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)
3477}
3478
3479fn lock_wasm_build_cache_with_progress(
3480 target_dir: &Path,
3481 progress: &mut ProgressReporter<'_>,
3482) -> Result<(File, Duration), WasmBuildError> {
3483 progress.begin_phase(WasmBuildFailurePhase::ExactCacheCoordination);
3484 create_dir_all(target_dir, "create Cargo target directory")?;
3485 let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
3486 lock_cache_file_with_progress(&lock_path, WasmBuildProgressPhase::ExactCacheLock, progress)
3487}
3488
3489fn lock_shared_incremental_target(
3490 spec: &WasmBuildSpec,
3491) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3492 lock_shared_incremental_target_internal(spec, None)
3493}
3494
3495fn lock_shared_incremental_target_with_progress(
3496 spec: &WasmBuildSpec,
3497 progress: &mut ProgressReporter<'_>,
3498) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3499 let target_dir = shared_incremental_target(spec)
3500 .expect("validated shared acquisition must have a shared Cargo target");
3501 progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted { target_dir });
3502 let (lock, wait, canonical) = lock_shared_incremental_target_internal(spec, Some(progress))?;
3503 progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
3504 target_dir: canonical.clone(),
3505 wait,
3506 });
3507 Ok((lock, wait, canonical))
3508}
3509
3510fn lock_shared_incremental_target_internal(
3511 spec: &WasmBuildSpec,
3512 mut progress: Option<&mut ProgressReporter<'_>>,
3513) -> Result<(File, Duration, PathBuf), WasmBuildError> {
3514 if let Some(progress) = progress.as_deref_mut() {
3515 progress.begin_phase(WasmBuildFailurePhase::SharedTargetCoordination);
3516 }
3517 let target_dir =
3518 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
3519 message: "shared incremental target is not configured".to_owned(),
3520 })?;
3521 create_dir_all(
3522 &target_dir,
3523 "create shared incremental Cargo target directory",
3524 )?;
3525 ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
3526 let canonical = target_dir
3527 .canonicalize()
3528 .map_err(|source| WasmBuildError::Io {
3529 operation: "resolve shared incremental Cargo target directory",
3530 path: target_dir.clone(),
3531 source,
3532 })?;
3533 let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
3534 let (lock, wait) = if let Some(progress) = progress {
3535 lock_cache_file_with_progress(
3536 &lock_path,
3537 WasmBuildProgressPhase::SharedTargetLock,
3538 progress,
3539 )?
3540 } else {
3541 lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)?
3542 };
3543 Ok((lock, wait, canonical))
3544}
3545
3546fn lock_cache_file_with_progress(
3547 lock_path: &Path,
3548 phase: WasmBuildProgressPhase,
3549 progress: &mut ProgressReporter<'_>,
3550) -> Result<(File, Duration), WasmBuildError> {
3551 let failure_phase = progress_failure_phase(phase);
3552 let started = Instant::now();
3553 progress.begin_phase(failure_phase);
3554 let result = if !progress.is_observed() || progress.config.heartbeat_interval.is_none() {
3555 lock_cache_file(lock_path).map_err(wasm_cache_fs_error)
3556 } else {
3557 let heartbeat_interval = progress
3558 .config
3559 .heartbeat_interval
3560 .expect("observed cache lock must have a heartbeat interval");
3561 lock_cache_file_with_wait_observer(lock_path, heartbeat_interval, |elapsed| {
3562 progress.emit_heartbeat_if_due(phase, elapsed);
3563 })
3564 .map_err(wasm_cache_fs_error)
3565 };
3566 progress.record_phase(failure_phase, started.elapsed());
3567 result
3568}
3569
3570fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
3571 ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
3572}
3573
3574fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
3575 record_entry_use(path).map_err(wasm_cache_fs_error)
3576}
3577
3578fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
3579 WasmBuildError::Io {
3580 operation: error.operation,
3581 path: error.path,
3582 source: error.source,
3583 }
3584}
3585
3586fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3587 if spec.packages.is_empty() {
3588 return Err(WasmBuildError::InvalidSpec {
3589 message: "at least one Cargo package is required".to_owned(),
3590 });
3591 }
3592 let mut profile_components = Path::new(&spec.profile_target_dir).components();
3593 if !matches!(
3594 (profile_components.next(), profile_components.next()),
3595 (Some(Component::Normal(_)), None)
3596 ) {
3597 return Err(WasmBuildError::InvalidSpec {
3598 message: "Cargo profile target directory must be one normal path component".to_owned(),
3599 });
3600 }
3601 if spec.target.is_empty() {
3602 return Err(WasmBuildError::InvalidSpec {
3603 message: "Cargo compilation target must not be empty".to_owned(),
3604 });
3605 }
3606 if spec.cargo_profile_args.iter().any(|argument| {
3607 matches!(argument.to_str(), Some("--help" | "--unit-graph"))
3608 || matches!(short_cargo_option(argument), Some((b'h', _)))
3609 }) {
3610 return Err(WasmBuildError::InvalidSpec {
3611 message:
3612 "Cargo help and build-graph flags exit without building and cannot be used for Wasm acquisition"
3613 .to_owned(),
3614 });
3615 }
3616 if spec.extra_env.contains_key(OsStr::new("CARGO_TARGET_DIR"))
3617 || spec.cargo_profile_args.iter().any(|argument| {
3618 argument == OsStr::new("--target-dir")
3619 || argument.as_encoded_bytes().starts_with(b"--target-dir=")
3620 })
3621 {
3622 return Err(WasmBuildError::InvalidSpec {
3623 message: "Cargo target directories are owned by the build specification; use target_dir or with_shared_incremental_target instead of command overrides".to_owned(),
3624 });
3625 }
3626 validate_cargo_target_selection(spec)?;
3627 if spec.cargo_profile_args.iter().any(|argument| {
3628 let option = argument
3629 .as_encoded_bytes()
3630 .split(|byte| *byte == b'=')
3631 .next()
3632 .unwrap_or_default();
3633 matches!(
3634 option,
3635 b"--manifest-path"
3636 | b"--target"
3637 | b"--package"
3638 | b"--workspace"
3639 | b"--all"
3640 | b"--exclude"
3641 | b"--config"
3642 ) || matches!(short_cargo_option(argument), Some((b'm' | b'p' | b'C', _)))
3643 }) {
3644 return Err(WasmBuildError::InvalidSpec {
3645 message: "Cargo workspace, package, target, and configuration inputs are owned by the build specification; use workspace_root, packages, with_target, and discovered Cargo configuration files or with_extra_env instead of command overrides".to_owned(),
3646 });
3647 }
3648 validate_cargo_profile(spec)?;
3649 if matches!(
3650 &spec.cache_mode,
3651 WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
3652 ) {
3653 return Err(WasmBuildError::InvalidSpec {
3654 message: "shared incremental Cargo target directory must not be empty".to_owned(),
3655 });
3656 }
3657 if spec.shared_incremental_maintenance_config.is_some()
3658 && !matches!(
3659 spec.cache_mode,
3660 WasmBuildCacheMode::SharedIncremental { .. }
3661 )
3662 {
3663 return Err(WasmBuildError::InvalidSpec {
3664 message:
3665 "scheduled shared-target maintenance requires a shared incremental Cargo target"
3666 .to_owned(),
3667 });
3668 }
3669 Ok(())
3670}
3671
3672fn validate_cargo_target_selection(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3673 if let Some(example) = &spec.example {
3674 let mut components = Path::new(example).components();
3675 if spec.packages.len() != 1
3676 || !matches!(
3677 (components.next(), components.next()),
3678 (Some(Component::Normal(_)), None)
3679 )
3680 || spec
3681 .cargo_profile_args
3682 .iter()
3683 .any(|argument| argument == "--lib")
3684 {
3685 return Err(WasmBuildError::InvalidSpec {
3686 message: "a named Cargo example requires one package, a normal target name and no --lib override".to_owned(),
3687 });
3688 }
3689 }
3690 if spec.packages.iter().any(|package| {
3691 let mut components = Path::new(package).components();
3692 !matches!(
3693 (components.next(), components.next()),
3694 (Some(Component::Normal(_)), None)
3695 )
3696 }) {
3697 return Err(WasmBuildError::InvalidSpec {
3698 message: "Cargo package names must be normal path components".to_owned(),
3699 });
3700 }
3701 if spec.cargo_profile_args.iter().any(|argument| {
3702 let option = argument
3703 .as_encoded_bytes()
3704 .split(|byte| *byte == b'=')
3705 .next()
3706 .unwrap_or_default();
3707 matches!(
3708 option,
3709 b"--bin"
3710 | b"--bins"
3711 | b"--example"
3712 | b"--examples"
3713 | b"--test"
3714 | b"--tests"
3715 | b"--bench"
3716 | b"--benches"
3717 | b"--all-targets"
3718 )
3719 }) {
3720 return Err(WasmBuildError::InvalidSpec {
3721 message: "Cargo target selection is owned by the build specification; use with_example for a named cdylib example".to_owned(),
3722 });
3723 }
3724 Ok(())
3725}
3726
3727fn validate_cargo_profile(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
3728 let mut selected = None;
3729 let mut arguments = spec.cargo_profile_args.iter();
3730 while let Some(argument) = arguments.next() {
3731 let profile = if argument == "--profile" {
3732 Some(
3733 arguments
3734 .next()
3735 .and_then(|value| value.to_str())
3736 .ok_or_else(|| WasmBuildError::InvalidSpec {
3737 message: "Cargo --profile requires a UTF-8 profile name".to_owned(),
3738 })?,
3739 )
3740 } else if let Some(profile) = argument.to_str().and_then(|s| s.strip_prefix("--profile=")) {
3741 Some(profile)
3742 } else {
3743 let bytes = argument.as_encoded_bytes();
3744 let short_option = short_cargo_option(argument);
3747 let flags_end = short_option.map_or(bytes.len(), |(_, index)| index);
3748 let release = argument == "--release"
3749 || (bytes.starts_with(b"-")
3750 && !bytes.starts_with(b"--")
3751 && bytes[..flags_end].contains(&b'r'));
3752 if matches!(short_option, Some((_, index)) if index + 1 == bytes.len()) {
3753 arguments.next();
3754 }
3755 release.then_some("release")
3756 };
3757 if let Some(profile) = profile
3758 && (profile.is_empty() || selected.replace(profile).is_some())
3759 {
3760 return Err(WasmBuildError::InvalidSpec {
3761 message: "select one nonempty Cargo profile".to_owned(),
3762 });
3763 }
3764 }
3765 let profile = selected.unwrap_or("dev");
3766 let expected = match profile {
3767 "dev" | "test" => "debug",
3768 "bench" => "release",
3769 custom => custom,
3770 };
3771 if spec.profile_target_dir != expected {
3772 return Err(WasmBuildError::InvalidSpec {
3773 message: format!(
3774 "Cargo profile {profile:?} writes to {expected:?}, but profile target directory is {:?}; select matching Cargo profile arguments and output directory",
3775 spec.profile_target_dir,
3776 ),
3777 });
3778 }
3779 Ok(())
3780}
3781
3782fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3783 build_fingerprint_with_progress(spec, &mut ProgressReporter::silent())
3784}
3785
3786fn build_fingerprint_with_progress(
3787 spec: &WasmBuildSpec,
3788 progress: &mut ProgressReporter<'_>,
3789) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3790 build_fingerprint_with_policy(spec, progress, &mut None)
3791}
3792
3793fn build_fingerprint_with_policy(
3794 spec: &WasmBuildSpec,
3795 progress: &mut ProgressReporter<'_>,
3796 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3797) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
3798 let total_started = Instant::now();
3799 let (cargo_identity, rustc_identity, tool_identity) =
3800 resolve_tool_identity_with_policy(spec, progress, policy)?;
3801
3802 let metadata_started = Instant::now();
3803 let metadata = progress.run_phase(WasmBuildProgressPhase::CargoMetadata, || {
3804 cargo_metadata_with_policy(spec, policy)
3805 })?;
3806 let cargo_metadata = metadata_started.elapsed();
3807
3808 let discovery_started = Instant::now();
3809 let (inputs, exclusions) =
3810 progress.run_phase(WasmBuildProgressPhase::InputDiscovery, || {
3811 let parsed = ParsedCargoMetadata::parse(&metadata)
3812 .map_err(|message| invalid_metadata(&message))?;
3813 resolve_local_inputs(spec, &parsed)
3814 })?;
3815 let input_discovery = discovery_started.elapsed();
3816
3817 let hashing_started = Instant::now();
3818 let (input_digest, validation_digest) =
3819 progress.run_phase(WasmBuildProgressPhase::ContentHashing, || {
3820 let mut cache = LabeledPathDigestCache::default();
3821 digest_resolved_local_inputs(
3822 &inputs,
3823 &exclusions,
3824 &mut cache,
3825 &spec.workspace_root,
3826 "hash Wasm build inputs",
3827 "hash semantic Wasm build inputs",
3828 )
3829 })?;
3830 let content_hashing = hashing_started.elapsed();
3831
3832 let fingerprint =
3833 finish_build_fingerprint(spec, &cargo_identity, &rustc_identity, input_digest);
3834 Ok(ResolvedCargoBuildInputs {
3835 fingerprint,
3836 input_digest,
3837 validation_digest,
3838 inputs: cargo_build_inputs(inputs.validation_inputs),
3839 exclusions: exclusions.into(),
3840 wasm_artifact_error: inputs.wasm_artifact_error,
3841 artifact_paths: expected_artifacts(spec, &spec.target_dir).into(),
3842 timings: WasmInputResolutionTimings {
3843 tool_identity,
3844 cargo_metadata,
3845 input_discovery,
3846 content_hashing,
3847 total: total_started.elapsed(),
3848 },
3849 })
3850}
3851
3852fn cargo_build_inputs(inputs: Vec<(PathBuf, PathBuf)>) -> Arc<[CargoBuildInput]> {
3853 inputs
3854 .into_iter()
3855 .map(|(label, path)| CargoBuildInput { label, path })
3856 .collect()
3857}
3858
3859fn finish_build_fingerprint(
3860 spec: &WasmBuildSpec,
3861 cargo_identity: &[u8],
3862 rustc_identity: &[u8],
3863 input_digest: InputDigest,
3864) -> InputDigest {
3865 let mut hasher = InputHasher::new(CACHE_FORMAT_VERSION);
3866 for package in &spec.packages {
3867 hasher.field("package", package.as_bytes());
3868 }
3869 hasher.field("target", spec.target.as_bytes());
3870 if let Some(example) = &spec.example {
3871 hasher.field("cargo-target-selection", b"example");
3872 hasher.field("cargo-example", example.as_bytes());
3873 } else {
3874 hasher.field("cargo-target-selection", b"lib");
3875 }
3876 hasher.field("profile-target-dir", spec.profile_target_dir.as_bytes());
3877 for argument in &spec.cargo_profile_args {
3878 hasher.field("cargo-argument", &os_bytes(argument));
3879 }
3880 for (key, value) in effective_environment(spec) {
3881 hasher.field("environment-key", &os_bytes(&key));
3882 if let Some(value) = value {
3883 hasher.field("environment-value", &os_bytes(&value));
3884 } else {
3885 hasher.field("environment-unset", b"");
3886 }
3887 }
3888 hasher.field("cargo-identity", cargo_identity);
3889 hasher.field("rustc-identity", rustc_identity);
3890 hasher.field("source-input-digest", input_digest.as_bytes());
3891 hasher.finish()
3892}
3893
3894#[cfg(test)]
3895fn command_identity(
3896 spec: &WasmBuildSpec,
3897 phase: WasmBuildPhase,
3898 program: &OsStr,
3899 arguments: &[&str],
3900) -> Result<Vec<u8>, WasmBuildError> {
3901 command_identity_with_policy(spec, phase, program, arguments, &mut None)
3902}
3903
3904fn command_identity_with_policy(
3905 spec: &WasmBuildSpec,
3906 phase: WasmBuildPhase,
3907 program: &OsStr,
3908 arguments: &[&str],
3909 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3910) -> Result<Vec<u8>, WasmBuildError> {
3911 let mut command = Command::new(program);
3912 command.current_dir(&spec.workspace_root).args(arguments);
3913 apply_command_environment(&mut command, spec);
3914 let output =
3915 capture_probe_output_with_policy(&mut command, phase, TOOL_IDENTITY_BYTES, policy)?;
3916 ensure_command_success(phase, output).map(|output| {
3917 let mut identity = output.stdout;
3918 identity.extend_from_slice(&output.stderr);
3919 identity
3920 })
3921}
3922
3923fn cargo_metadata(spec: &WasmBuildSpec) -> Result<Value, WasmBuildError> {
3924 cargo_metadata_with_policy(spec, &mut None)
3925}
3926
3927fn cargo_metadata_with_policy(
3928 spec: &WasmBuildSpec,
3929 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3930) -> Result<Value, WasmBuildError> {
3931 let mut command = Command::new(&spec.cargo_program);
3932 command
3933 .current_dir(&spec.workspace_root)
3934 .args(["metadata", "--format-version", "1"]);
3935 for argument in metadata_arguments(&spec.cargo_profile_args) {
3936 command.arg(argument);
3937 }
3938 apply_command_environment(&mut command, spec);
3939 let output = capture_probe_output_with_policy(
3940 &mut command,
3941 WasmBuildPhase::CargoMetadata,
3942 CARGO_METADATA_BYTES,
3943 policy,
3944 )?;
3945 let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
3946 serde_json::from_slice(&output.stdout).map_err(|error| WasmBuildError::InvalidMetadata {
3947 message: format!("Cargo metadata was not valid JSON: {error}"),
3948 })
3949}
3950
3951#[cfg(test)]
3952fn capture_probe_output(
3953 command: &mut Command,
3954 phase: WasmBuildPhase,
3955 stdout_bytes: usize,
3956) -> Result<CompletedExecution, WasmBuildError> {
3957 capture_probe_output_with_policy(command, phase, stdout_bytes, &mut None)
3958}
3959
3960fn capture_probe_output_with_policy(
3961 command: &mut Command,
3962 phase: WasmBuildPhase,
3963 stdout_bytes: usize,
3964 policy: &mut Option<&mut CargoInputProbePolicy<'_>>,
3965) -> Result<CompletedExecution, WasmBuildError> {
3966 let limits = OutputLimits {
3967 stdout: OutputLimit::Terminate(stdout_bytes),
3968 stderr: OutputLimit::Terminate(TOOL_IDENTITY_BYTES),
3969 timeout: None,
3970 };
3971 let result = if let Some(policy) = policy {
3972 capture_controlled_probe(command, limits, policy)
3973 } else {
3974 capture_command(command, limits)
3975 }
3976 .and_then(CompletedExecution::require_complete);
3977 let evidence = match result {
3978 Ok(evidence) => evidence,
3979 Err(ToolError::Execution(error))
3980 if matches!(
3981 error.failure,
3982 ExecutionFailure::Io {
3983 operation: ExecutionOperation::Spawn,
3984 ..
3985 }
3986 ) && error.cleanup.is_none() =>
3987 {
3988 let ExecutionFailure::Io { source, .. } = error.failure else {
3989 unreachable!()
3990 };
3991 return Err(WasmBuildError::CommandSpawn {
3992 phase,
3993 program: command.get_program().to_owned(),
3994 source,
3995 });
3996 }
3997 Err(error) => {
3998 return Err(WasmBuildError::Io {
3999 operation: match phase {
4000 WasmBuildPhase::CargoIdentity => "capture complete Cargo identity probe output",
4001 WasmBuildPhase::RustcIdentity => "capture complete rustc identity probe output",
4002 _ => "capture complete Cargo metadata probe output",
4003 },
4004 path: PathBuf::from(command.get_program()),
4005 source: io::Error::other(error),
4006 });
4007 }
4008 };
4009 Ok(evidence)
4010}
4011
4012fn capture_controlled_probe(
4013 command: &mut Command,
4014 mut limits: OutputLimits,
4015 policy: &mut CargoInputProbePolicy<'_>,
4016) -> Result<CompletedExecution, ToolError> {
4017 let failure = if (policy.cancelled)() {
4018 Some(ExecutionFailure::Cancelled)
4019 } else if policy
4020 .deadline
4021 .is_some_and(|deadline| deadline <= Instant::now())
4022 {
4023 Some(ExecutionFailure::TimedOut)
4024 } else {
4025 None
4026 };
4027 if let Some(failure) = failure {
4028 return Err(ToolError::Execution(Box::new(ExecutionError {
4029 failure,
4030 evidence: ExecutionEvidence::default(),
4031 cleanup: None,
4032 })));
4033 }
4034 command
4035 .stdin(Stdio::null())
4036 .stdout(Stdio::piped())
4037 .stderr(Stdio::piped());
4038 let mut child = OwnedChild::spawn_with_cleanup(command, policy.cleanup).map_err(|source| {
4039 ToolError::Execution(Box::new(ExecutionError {
4040 failure: ExecutionFailure::Io {
4041 operation: ExecutionOperation::Spawn,
4042 source,
4043 },
4044 evidence: ExecutionEvidence::default(),
4045 cleanup: None,
4046 }))
4047 })?;
4048 limits.timeout = policy.deadline.map(|deadline| {
4050 deadline
4051 .saturating_duration_since(Instant::now())
4052 .max(Duration::from_nanos(1))
4053 });
4054 communicate_child(
4055 &mut child,
4056 None,
4057 limits,
4058 SuccessfulExit::Cleanup,
4059 &mut *policy.cancelled,
4060 )
4061}
4062
4063fn metadata_arguments(arguments: &[OsString]) -> Vec<OsString> {
4064 let mut selected = Vec::new();
4065 let mut arguments = arguments.iter();
4066 while let Some(argument) = arguments.next() {
4067 if let Some((b'F', index)) = short_cargo_option(argument) {
4068 selected.push(argument.to_str().map_or_else(
4073 || argument.clone(),
4074 |text| OsString::from(format!("-F{}", &text[index + 1..])),
4075 ));
4076 if index + 1 == argument.as_encoded_bytes().len()
4077 && let Some(value) = arguments.next()
4078 {
4079 selected.push(value.clone());
4080 }
4081 continue;
4082 }
4083 let argument_text = argument.to_string_lossy();
4084 match argument_text.as_ref() {
4085 "--all-features" | "--no-default-features" | "--locked" | "--offline" | "--frozen" => {
4086 selected.push(argument.clone());
4087 }
4088 "--features" | "--filter-platform" => {
4089 selected.push(argument.clone());
4090 if let Some(value) = arguments.next() {
4091 selected.push(value.clone());
4092 }
4093 }
4094 _ if argument_text.starts_with("--features=")
4095 || argument_text.starts_with("--filter-platform=") =>
4096 {
4097 selected.push(argument.clone());
4098 }
4099 _ => {}
4100 }
4101 }
4102 selected
4103}
4104
4105fn short_cargo_option(argument: &OsStr) -> Option<(u8, usize)> {
4109 let bytes = argument.as_encoded_bytes();
4110 if !bytes.starts_with(b"-") || bytes.starts_with(b"--") {
4111 return None;
4112 }
4113 for (index, byte) in bytes.iter().copied().enumerate().skip(1) {
4114 match byte {
4115 b'v' | b'q' | b'r' => {}
4116 b'h' | b'F' | b'j' | b'Z' | b'm' | b'p' | b'C' => return Some((byte, index)),
4117 _ => return None,
4118 }
4119 }
4120 None
4121}
4122
4123struct MetadataPackage<'a> {
4124 id: &'a str,
4125 name: &'a str,
4126 version: &'a str,
4127 manifest_path: PathBuf,
4128 is_local: bool,
4129 source: Option<&'a str>,
4130 semantic_fields: Vec<(&'static str, Option<String>)>,
4131 cdylib_name: Option<&'a str>,
4132 cdylib_examples: Vec<&'a str>,
4133}
4134
4135struct ParsedCargoMetadata<'a> {
4138 packages: HashMap<&'a str, MetadataPackage<'a>>,
4139 workspace_members: HashSet<&'a str>,
4140 nodes: HashMap<&'a str, MetadataNode<'a>>,
4141 workspace_root: Option<&'a str>,
4142}
4143
4144struct MetadataNode<'a> {
4145 dependencies: Vec<&'a str>,
4146 value: &'a Value,
4147}
4148
4149impl<'a> ParsedCargoMetadata<'a> {
4150 fn parse(metadata: &'a Value) -> Result<Self, String> {
4151 let packages = metadata_packages(metadata)?;
4152 let workspace_members = metadata
4153 .get("workspace_members")
4154 .and_then(Value::as_array)
4155 .ok_or_else(|| "Cargo metadata has no workspace member array".to_owned())?
4156 .iter()
4157 .filter_map(Value::as_str)
4158 .collect();
4159 let values = metadata
4160 .pointer("/resolve/nodes")
4161 .and_then(Value::as_array)
4162 .ok_or_else(|| "Cargo metadata has no resolved dependency nodes".to_owned())?;
4163 let mut nodes = HashMap::new();
4164 for value in values {
4165 let id = required_string(value, "id")?;
4166 let dependencies = value
4167 .get("deps")
4168 .and_then(Value::as_array)
4169 .ok_or_else(|| "Cargo metadata dependency node has no deps array".to_owned())?
4170 .iter()
4171 .map(|dependency| required_string(dependency, "pkg"))
4172 .collect::<Result<_, _>>()?;
4173 nodes.insert(
4174 id,
4175 MetadataNode {
4176 dependencies,
4177 value,
4178 },
4179 );
4180 }
4181 Ok(Self {
4182 packages,
4183 workspace_members,
4184 nodes,
4185 workspace_root: metadata.get("workspace_root").and_then(Value::as_str),
4186 })
4187 }
4188}
4189
4190const SEMANTIC_PACKAGE_FIELDS: &[&str] = &[
4191 "authors",
4192 "default_run",
4193 "description",
4194 "documentation",
4195 "edition",
4196 "homepage",
4197 "license",
4198 "license_file",
4199 "links",
4200 "metadata",
4201 "name",
4202 "readme",
4203 "repository",
4204 "rust_version",
4205 "version",
4206];
4207
4208struct LockedPackageIdentity {
4209 name: String,
4210 version: String,
4211 source: String,
4212 checksum: Option<String>,
4213}
4214
4215fn resolve_local_inputs(
4216 spec: &WasmBuildSpec,
4217 metadata: &ParsedCargoMetadata<'_>,
4218) -> Result<(ResolvedLocalInputs, Vec<PathBuf>), WasmBuildError> {
4219 let mut selected_ids = selected_package_ids(spec, metadata)?;
4220 let mut artifact_names = BTreeSet::new();
4223 let wasm_artifact_error = selected_ids.iter().find_map(|id| {
4224 let package = &metadata.packages[id];
4225 if let Some(example) = &spec.example {
4226 return (!package.cdylib_examples.contains(&example.as_str())).then(|| {
4227 format!("Cargo package `{}` must declare the cdylib example `{example}`", package.name)
4228 });
4229 }
4230 let library = default_library_name(package.name);
4231 if package.cdylib_name != Some(library.as_str()) {
4232 Some(format!(
4233 "Cargo package `{}` must declare a cdylib library named `{}` to produce its expected Wasm artifact",
4234 package.name, library,
4235 ))
4236 } else if !artifact_names.insert(library.clone()) {
4237 Some(format!("selected Cargo packages share the Wasm artifact name `{library}`"))
4238 } else {
4239 None
4240 }
4241 });
4242 let mut closure = BTreeSet::new();
4243 while let Some(id) = selected_ids.pop_front() {
4244 if !closure.insert(id) {
4245 continue;
4246 }
4247 if let Some(node) = metadata.nodes.get(id) {
4248 selected_ids.extend(node.dependencies.iter().copied());
4249 }
4250 }
4251
4252 let workspace_root = metadata
4253 .workspace_root
4254 .map_or_else(|| spec.workspace_root.clone(), PathBuf::from);
4255 let workspace_projection = semantic_workspace_projection(metadata, &closure, &workspace_root)?;
4256 let mut validation_inputs = workspace_configuration_inputs(spec, &workspace_root)?;
4257 append_package_inputs(
4258 &mut validation_inputs,
4259 &metadata.packages,
4260 closure,
4261 &workspace_root,
4262 )?;
4263 append_additional_inputs(&mut validation_inputs, spec, &workspace_root);
4264 validate_shared_incremental_target_boundary(spec, &validation_inputs)?;
4265 let exclusions = source_exclusions(spec, &validation_inputs);
4266 Ok((
4267 ResolvedLocalInputs {
4268 validation_inputs,
4269 workspace_projection,
4270 wasm_artifact_error,
4271 },
4272 exclusions,
4273 ))
4274}
4275
4276fn metadata_packages(metadata: &Value) -> Result<HashMap<&str, MetadataPackage<'_>>, String> {
4277 let packages_value = metadata
4278 .get("packages")
4279 .and_then(Value::as_array)
4280 .ok_or_else(|| "Cargo metadata has no package array".to_owned())?;
4281 let mut packages = HashMap::new();
4282 for value in packages_value {
4283 let source = optional_string(value, "source")?;
4284 let package = MetadataPackage {
4285 id: required_string(value, "id")?,
4286 name: required_string(value, "name")?,
4287 version: required_string(value, "version")?,
4288 manifest_path: PathBuf::from(required_string(value, "manifest_path")?),
4289 is_local: value.get("source").is_some_and(Value::is_null),
4290 source,
4291 semantic_fields: SEMANTIC_PACKAGE_FIELDS
4292 .iter()
4293 .map(|field| (*field, value.get(*field).map(Value::to_string)))
4294 .collect(),
4295 cdylib_name: value
4296 .get("targets")
4297 .and_then(Value::as_array)
4298 .and_then(|targets| {
4299 targets.iter().find(|target| {
4300 target
4301 .get("kind")
4302 .and_then(Value::as_array)
4303 .is_some_and(|kinds| kinds.iter().any(|kind| kind == "cdylib"))
4304 })
4305 })
4306 .and_then(|target| target.get("name"))
4307 .and_then(Value::as_str),
4308 cdylib_examples: value
4309 .get("targets")
4310 .and_then(Value::as_array)
4311 .into_iter()
4312 .flatten()
4313 .filter(|target| {
4314 target
4315 .get("kind")
4316 .and_then(Value::as_array)
4317 .is_some_and(|kinds| kinds.iter().any(|kind| kind == "example"))
4318 && target
4319 .get("crate_types")
4320 .and_then(Value::as_array)
4321 .is_some_and(|types| types.iter().any(|kind| kind == "cdylib"))
4322 })
4323 .filter_map(|target| target.get("name").and_then(Value::as_str))
4324 .collect(),
4325 };
4326 packages.insert(package.id, package);
4327 }
4328 Ok(packages)
4329}
4330
4331fn selected_package_ids<'a>(
4332 spec: &WasmBuildSpec,
4333 metadata: &ParsedCargoMetadata<'a>,
4334) -> Result<VecDeque<&'a str>, WasmBuildError> {
4335 let mut selected_ids = VecDeque::new();
4336 for requested in &spec.packages {
4337 let mut matches = metadata
4338 .packages
4339 .values()
4340 .filter(|package| {
4341 package.name == *requested && metadata.workspace_members.contains(package.id)
4342 })
4343 .map(|package| package.id);
4344 match (matches.next(), matches.next()) {
4345 (Some(id), None) => selected_ids.push_back(id),
4346 (None, _) => {
4347 return Err(WasmBuildError::InvalidSpec {
4348 message: format!("Cargo workspace contains no package named `{requested}`"),
4349 });
4350 }
4351 _ => {
4352 return Err(WasmBuildError::InvalidSpec {
4353 message: format!("Cargo workspace package name `{requested}` is ambiguous"),
4354 });
4355 }
4356 }
4357 }
4358 Ok(selected_ids)
4359}
4360
4361fn semantic_workspace_projection(
4362 metadata: &ParsedCargoMetadata<'_>,
4363 closure: &BTreeSet<&str>,
4364 workspace_root: &Path,
4365) -> Result<Option<InputDigest>, WasmBuildError> {
4366 let locked_packages = locked_package_identities(workspace_root)?;
4369 let mut identities = HashMap::new();
4370 for &id in closure {
4371 let package = metadata
4372 .packages
4373 .get(id)
4374 .ok_or_else(|| invalid_metadata(&format!("resolved package `{id}` is missing")))?;
4375 let Some(identity) = semantic_package_identity(package, workspace_root, &locked_packages)
4376 else {
4377 return Ok(None);
4378 };
4379 identities.insert(id, identity);
4380 }
4381
4382 let mut projected_packages = closure
4383 .iter()
4384 .map(|&id| {
4385 let package = metadata
4386 .packages
4387 .get(id)
4388 .expect("selected package closure was validated above");
4389 let identity = identities[id];
4390 let node = metadata.nodes.get(id).ok_or_else(|| {
4391 invalid_metadata(&format!("resolved package `{id}` has no dependency node"))
4392 })?;
4393 let projection = semantic_package_projection(package, node.value, &identities)?;
4394 Ok::<_, WasmBuildError>((identity, projection))
4395 })
4396 .collect::<Result<Vec<_>, _>>()?;
4397 projected_packages.sort_by_key(|(identity, _)| *identity);
4398
4399 let root_manifest = workspace_root.join("Cargo.toml");
4400 let root_contents =
4401 fs::read_to_string(&root_manifest).map_err(|source| WasmBuildError::Io {
4402 operation: "read workspace manifest for semantic projection",
4403 path: root_manifest.clone(),
4404 source,
4405 })?;
4406 let root = toml::from_str::<TomlValue>(&root_contents).map_err(|error| {
4407 invalid_metadata(&format!(
4408 "workspace manifest could not be projected as TOML: {error}"
4409 ))
4410 })?;
4411
4412 let mut hasher = InputHasher::new("wasm-semantic-workspace-projection-v1");
4413 for (identity, projection) in projected_packages {
4414 hasher.field("package-identity", identity.as_bytes());
4415 hasher.field("package-projection", projection.as_bytes());
4416 }
4417 hash_toml_setting(&mut hasher, "cargo-features", root.get("cargo-features"));
4418 hash_toml_setting(&mut hasher, "profile", root.get("profile"));
4419 let workspace = root.get("workspace").and_then(TomlValue::as_table);
4420 hash_toml_setting(
4421 &mut hasher,
4422 "workspace-resolver",
4423 workspace.and_then(|table| table.get("resolver")),
4424 );
4425 hash_toml_setting(
4426 &mut hasher,
4427 "workspace-lints",
4428 workspace.and_then(|table| table.get("lints")),
4429 );
4430 Ok(Some(hasher.finish()))
4431}
4432
4433fn locked_package_identities(
4434 workspace_root: &Path,
4435) -> Result<Vec<LockedPackageIdentity>, WasmBuildError> {
4436 let lockfile = workspace_root.join("Cargo.lock");
4437 let contents = match fs::read_to_string(&lockfile) {
4438 Ok(contents) => contents,
4439 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
4440 Err(source) => {
4441 return Err(WasmBuildError::Io {
4442 operation: "read Cargo lockfile for semantic projection",
4443 path: lockfile,
4444 source,
4445 });
4446 }
4447 };
4448 let lock = toml::from_str::<TomlValue>(&contents).map_err(|error| {
4449 invalid_metadata(&format!(
4450 "Cargo lockfile could not be projected as TOML: {error}"
4451 ))
4452 })?;
4453 let Some(packages) = lock.get("package").and_then(TomlValue::as_array) else {
4454 return Ok(Vec::new());
4455 };
4456 packages
4457 .iter()
4458 .filter_map(|package| {
4459 let Some(table) = package.as_table() else {
4460 return Some(Err(invalid_metadata(
4461 "Cargo lockfile package entry is not a table",
4462 )));
4463 };
4464 let source = table.get("source")?.as_str().map(str::to_owned);
4465 Some(
4466 source
4467 .ok_or_else(|| {
4468 invalid_metadata("Cargo lockfile package source is not a string")
4469 })
4470 .and_then(|source| {
4471 Ok(LockedPackageIdentity {
4472 name: required_toml_string(table, "name", "Cargo lockfile package")?,
4473 version: required_toml_string(
4474 table,
4475 "version",
4476 "Cargo lockfile package",
4477 )?,
4478 source,
4479 checksum: optional_toml_string(
4480 table,
4481 "checksum",
4482 "Cargo lockfile package",
4483 )?,
4484 })
4485 }),
4486 )
4487 })
4488 .collect()
4489}
4490
4491fn required_toml_string(
4492 table: &toml::Table,
4493 field: &str,
4494 context: &str,
4495) -> Result<String, WasmBuildError> {
4496 table
4497 .get(field)
4498 .and_then(TomlValue::as_str)
4499 .map(str::to_owned)
4500 .ok_or_else(|| invalid_metadata(&format!("{context} `{field}` is missing or not a string")))
4501}
4502
4503fn optional_toml_string(
4504 table: &toml::Table,
4505 field: &str,
4506 context: &str,
4507) -> Result<Option<String>, WasmBuildError> {
4508 match table.get(field) {
4509 None => Ok(None),
4510 Some(TomlValue::String(value)) => Ok(Some(value.clone())),
4511 Some(_) => Err(invalid_metadata(&format!(
4512 "{context} `{field}` is not a string"
4513 ))),
4514 }
4515}
4516
4517fn semantic_package_identity(
4518 package: &MetadataPackage<'_>,
4519 workspace_root: &Path,
4520 locked_packages: &[LockedPackageIdentity],
4521) -> Option<InputDigest> {
4522 let mut hasher = InputHasher::new("wasm-semantic-package-identity-v1");
4523 hasher.field("name", package.name.as_bytes());
4524 hasher.field("version", package.version.as_bytes());
4525 if package.is_local {
4526 let manifest = package.manifest_path.strip_prefix(workspace_root).ok()?;
4527 let package_root = package.manifest_path.parent()?;
4528 if package_root == workspace_root {
4529 return None;
4530 }
4531 hasher.field("local-manifest", &os_bytes(manifest.as_os_str()));
4532 } else {
4533 let metadata_source = package.source?;
4534 let locked = locked_packages.iter().find(|locked| {
4535 locked.name == package.name
4536 && locked.version == package.version
4537 && locked.source == metadata_source
4538 })?;
4539 match locked.source.as_str() {
4540 source if source.starts_with("registry+") && locked.checksum.is_some() => {}
4541 source if source.starts_with("git+") && source.contains('#') => {}
4542 _ => return None,
4543 }
4544 hasher.field("external-package-id", package.id.as_bytes());
4545 hasher.field("external-source", locked.source.as_bytes());
4546 hasher.field(
4547 "external-checksum",
4548 locked.checksum.as_deref().unwrap_or_default().as_bytes(),
4549 );
4550 }
4551 Some(hasher.finish())
4552}
4553
4554fn semantic_package_projection(
4555 package: &MetadataPackage<'_>,
4556 node: &Value,
4557 identities: &HashMap<&str, InputDigest>,
4558) -> Result<InputDigest, WasmBuildError> {
4559 let mut hasher = InputHasher::new("wasm-semantic-package-projection-v1");
4563 for (field, value) in &package.semantic_fields {
4564 hasher.field("package-field-name", field.as_bytes());
4565 match value {
4566 Some(value) => hasher.field("package-field-value", value.as_bytes()),
4567 None => hasher.field("package-field-missing", b""),
4568 }
4569 }
4570
4571 let mut features = node
4572 .get("features")
4573 .and_then(Value::as_array)
4574 .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no features array"))?
4575 .iter()
4576 .map(|feature| {
4577 feature.as_str().ok_or_else(|| {
4578 invalid_metadata("Cargo metadata dependency feature is not a string")
4579 })
4580 })
4581 .collect::<Result<Vec<_>, _>>()?;
4582 features.sort_unstable();
4583 for feature in features {
4584 hasher.field("enabled-feature", feature.as_bytes());
4585 }
4586
4587 let mut dependencies = node
4588 .get("deps")
4589 .and_then(Value::as_array)
4590 .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
4591 .iter()
4592 .map(|dependency| {
4593 let name = required_string(dependency, "name")
4594 .map_err(|message| invalid_metadata(&message))?;
4595 let package_id =
4596 required_string(dependency, "pkg").map_err(|message| invalid_metadata(&message))?;
4597 let identity = identities.get(package_id).copied().ok_or_else(|| {
4598 invalid_metadata(&format!(
4599 "dependency `{package_id}` is outside the selected package closure"
4600 ))
4601 })?;
4602 let kinds = dependency
4603 .get("dep_kinds")
4604 .ok_or_else(|| invalid_metadata("Cargo metadata dependency has no kind array"))?
4605 .to_string();
4606 Ok::<_, WasmBuildError>((name, identity, kinds))
4607 })
4608 .collect::<Result<Vec<_>, _>>()?;
4609 dependencies.sort();
4610 for (name, identity, kinds) in dependencies {
4611 hasher.field("dependency-name", name.as_bytes());
4612 hasher.field("dependency-identity", identity.as_bytes());
4613 hasher.field("dependency-kinds", kinds.as_bytes());
4614 }
4615 Ok(hasher.finish())
4616}
4617
4618fn hash_toml_setting(hasher: &mut InputHasher, label: &str, value: Option<&TomlValue>) {
4619 hasher.field("workspace-setting-name", label.as_bytes());
4620 match value {
4621 Some(value) => hasher.field("workspace-setting-value", value.to_string().as_bytes()),
4622 None => hasher.field("workspace-setting-missing", b""),
4623 }
4624}
4625
4626fn is_broad_workspace_input(label: &Path) -> bool {
4627 label == Path::new("workspace/Cargo.toml") || label == Path::new("workspace/Cargo.lock")
4628}
4629
4630fn digest_resolved_local_inputs(
4631 inputs: &ResolvedLocalInputs,
4632 exclusions: &[PathBuf],
4633 cache: &mut LabeledPathDigestCache,
4634 error_path: &Path,
4635 validation_operation: &'static str,
4636 semantic_operation: &'static str,
4637) -> Result<(InputDigest, InputDigest), WasmBuildError> {
4638 let validation_digest = digest_labeled_paths_composable(
4639 "wasm-source-inputs-v1",
4640 inputs
4641 .validation_inputs
4642 .iter()
4643 .map(|(label, path)| (label.as_path(), path.as_path())),
4644 exclusions,
4645 cache,
4646 )
4647 .map_err(|source| WasmBuildError::Io {
4648 operation: validation_operation,
4649 path: error_path.to_owned(),
4650 source,
4651 })?;
4652 let input_digest = semantic_input_digest(inputs, validation_digest, exclusions, cache)
4653 .map_err(|source| WasmBuildError::Io {
4654 operation: semantic_operation,
4655 path: error_path.to_owned(),
4656 source,
4657 })?;
4658 Ok((input_digest, validation_digest))
4659}
4660
4661fn semantic_input_digest(
4662 inputs: &ResolvedLocalInputs,
4663 validation_digest: InputDigest,
4664 exclusions: &[PathBuf],
4665 cache: &mut LabeledPathDigestCache,
4666) -> io::Result<InputDigest> {
4667 let Some(workspace) = inputs.workspace_projection else {
4668 return Ok(validation_digest);
4669 };
4670 let path_digest = digest_labeled_paths_composable(
4671 "wasm-source-inputs-v1",
4672 inputs
4673 .validation_inputs
4674 .iter()
4675 .filter(|(label, _)| !is_broad_workspace_input(label))
4676 .map(|(label, path)| (label.as_path(), path.as_path())),
4677 exclusions,
4678 cache,
4679 )?;
4680 let mut hasher = InputHasher::new("wasm-semantic-source-inputs-v1");
4681 hasher.field("path-input-digest", path_digest.as_bytes());
4682 hasher.field("workspace-projection", workspace.as_bytes());
4683 Ok(hasher.finish())
4684}
4685
4686fn workspace_configuration_inputs(
4687 spec: &WasmBuildSpec,
4688 workspace_root: &Path,
4689) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
4690 let mut inputs = Vec::new();
4691 add_if_present(
4692 &mut inputs,
4693 "workspace/Cargo.toml",
4694 workspace_root.join("Cargo.toml"),
4695 );
4696 add_if_present(
4697 &mut inputs,
4698 "workspace/Cargo.lock",
4699 workspace_root.join("Cargo.lock"),
4700 );
4701 add_if_present(
4702 &mut inputs,
4703 "workspace/rust-toolchain.toml",
4704 workspace_root.join("rust-toolchain.toml"),
4705 );
4706 add_if_present(
4707 &mut inputs,
4708 "workspace/rust-toolchain",
4709 workspace_root.join("rust-toolchain"),
4710 );
4711 append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
4712 Ok(inputs)
4713}
4714
4715fn append_cargo_configuration_inputs(
4716 inputs: &mut Vec<(PathBuf, PathBuf)>,
4717 spec: &WasmBuildSpec,
4718 workspace_root: &Path,
4719) -> Result<(), WasmBuildError> {
4720 let invocation_root =
4721 spec.workspace_root
4722 .canonicalize()
4723 .map_err(|source| WasmBuildError::Io {
4724 operation: "resolve Cargo invocation directory",
4725 path: spec.workspace_root.clone(),
4726 source,
4727 })?;
4728 let canonical_workspace =
4729 workspace_root
4730 .canonicalize()
4731 .map_err(|source| WasmBuildError::Io {
4732 operation: "resolve Cargo workspace directory",
4733 path: workspace_root.to_owned(),
4734 source,
4735 })?;
4736
4737 let mut roots = invocation_root
4738 .ancestors()
4739 .filter_map(|directory| effective_cargo_config(&directory.join(".cargo")))
4740 .collect::<Vec<_>>();
4741 if let Some(cargo_home) = effective_cargo_home(spec, &invocation_root)
4742 && let Some(config) = effective_cargo_config(&cargo_home)
4743 {
4744 roots.push(config);
4745 }
4746
4747 let mut active = BTreeSet::new();
4748 for config in roots {
4749 append_cargo_configuration_tree(inputs, &config, &canonical_workspace, &mut active, false)?;
4750 }
4751 Ok(())
4752}
4753
4754fn effective_cargo_config(directory: &Path) -> Option<PathBuf> {
4755 let extensionless = directory.join("config");
4756 if extensionless.exists() {
4757 return Some(extensionless);
4758 }
4759 let toml = directory.join("config.toml");
4760 toml.exists().then_some(toml)
4761}
4762
4763fn effective_cargo_home(spec: &WasmBuildSpec, invocation_root: &Path) -> Option<PathBuf> {
4764 if let Some(cargo_home) = command_environment_value(spec, "CARGO_HOME") {
4765 let cargo_home = PathBuf::from(cargo_home);
4766 return Some(if cargo_home.is_absolute() {
4767 cargo_home
4768 } else {
4769 invocation_root.join(cargo_home)
4770 });
4771 }
4772
4773 default_home_directory(spec).map(|home| {
4774 let home = if home.is_absolute() {
4775 home
4776 } else {
4777 invocation_root.join(home)
4778 };
4779 home.join(".cargo")
4780 })
4781}
4782
4783#[cfg(windows)]
4784fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4785 command_environment_value(spec, "USERPROFILE")
4786 .or_else(|| command_environment_value(spec, "HOME"))
4787 .map(PathBuf::from)
4788}
4789
4790#[cfg(not(windows))]
4791fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
4792 command_environment_value(spec, "HOME").map(PathBuf::from)
4793}
4794
4795fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
4796 spec.extra_env
4797 .get(OsStr::new(name))
4798 .cloned()
4799 .or_else(|| std::env::var_os(name))
4800}
4801
4802fn append_cargo_configuration_tree(
4803 inputs: &mut Vec<(PathBuf, PathBuf)>,
4804 config: &Path,
4805 workspace_root: &Path,
4806 active: &mut BTreeSet<PathBuf>,
4807 optional: bool,
4808) -> Result<(), WasmBuildError> {
4809 let contents = match fs::read_to_string(config) {
4810 Ok(contents) => contents,
4811 Err(error) if optional && error.kind() == io::ErrorKind::NotFound => return Ok(()),
4812 Err(source) => {
4813 return Err(WasmBuildError::Io {
4814 operation: "read Cargo configuration",
4815 path: config.to_owned(),
4816 source,
4817 });
4818 }
4819 };
4820 let parent = config
4821 .parent()
4822 .ok_or_else(|| WasmBuildError::InvalidCargoConfiguration {
4823 path: config.to_owned(),
4824 message: "configuration path has no parent directory".to_owned(),
4825 })?;
4826 let location = parent
4829 .canonicalize()
4830 .map_err(|source| WasmBuildError::Io {
4831 operation: "resolve Cargo configuration directory",
4832 path: parent.to_owned(),
4833 source,
4834 })?
4835 .join(config.file_name().ok_or_else(|| {
4836 invalid_cargo_configuration(config, "configuration path has no file name")
4837 })?);
4838 if !active.insert(location.clone()) {
4841 return Ok(());
4842 }
4843 let configuration = toml::from_str::<TomlValue>(&contents).map_err(|error| {
4844 WasmBuildError::InvalidCargoConfiguration {
4845 path: config.to_owned(),
4846 message: error.to_string(),
4847 }
4848 })?;
4849 if !inputs.iter().any(|(_, path)| path == config) {
4852 inputs.push((
4853 cargo_configuration_label(&location, workspace_root),
4854 config.to_owned(),
4855 ));
4856 }
4857 if let Some(include) = configuration.get("include") {
4858 for (included, optional) in cargo_configuration_includes(include, config)? {
4859 let included = if included.is_absolute() {
4860 included
4861 } else {
4862 parent.join(included)
4863 };
4864 append_cargo_configuration_tree(inputs, &included, workspace_root, active, optional)?;
4865 }
4866 }
4867 active.remove(&location);
4868 Ok(())
4869}
4870
4871fn cargo_configuration_includes(
4872 include: &TomlValue,
4873 config: &Path,
4874) -> Result<Vec<(PathBuf, bool)>, WasmBuildError> {
4875 let values = match include {
4876 TomlValue::Array(values) => values.as_slice(),
4877 value => std::slice::from_ref(value),
4878 };
4879 values
4880 .iter()
4881 .map(|value| match value {
4882 TomlValue::String(path) => Ok((PathBuf::from(path), false)),
4883 TomlValue::Table(table) => {
4884 let path = table
4885 .get("path")
4886 .and_then(TomlValue::as_str)
4887 .ok_or_else(|| {
4888 invalid_cargo_configuration(
4889 config,
4890 "Cargo configuration include table requires a string `path`",
4891 )
4892 })?;
4893 let optional = table
4894 .get("optional")
4895 .map(|value| {
4896 value.as_bool().ok_or_else(|| {
4897 invalid_cargo_configuration(
4898 config,
4899 "Cargo configuration include `optional` must be a boolean",
4900 )
4901 })
4902 })
4903 .transpose()?
4904 .unwrap_or(false);
4905 Ok((PathBuf::from(path), optional))
4906 }
4907 _ => Err(invalid_cargo_configuration(
4908 config,
4909 "Cargo configuration `include` must contain paths or include tables",
4910 )),
4911 })
4912 .collect()
4913}
4914
4915fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
4916 if let Ok(relative) = config.strip_prefix(workspace_root) {
4917 return PathBuf::from("cargo-config/workspace").join(relative);
4918 }
4919 let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
4920 PathBuf::from("cargo-config/external").join(location.to_hex())
4921}
4922
4923fn invalid_cargo_configuration(path: &Path, message: &str) -> WasmBuildError {
4924 WasmBuildError::InvalidCargoConfiguration {
4925 path: path.to_owned(),
4926 message: message.to_owned(),
4927 }
4928}
4929
4930fn append_package_inputs(
4931 inputs: &mut Vec<(PathBuf, PathBuf)>,
4932 packages: &HashMap<&str, MetadataPackage<'_>>,
4933 closure: BTreeSet<&str>,
4934 workspace_root: &Path,
4935) -> Result<(), WasmBuildError> {
4936 for id in closure {
4937 let Some(package) = packages.get(id) else {
4938 return Err(invalid_metadata(&format!(
4939 "resolved package `{id}` is missing"
4940 )));
4941 };
4942 if !package.is_local {
4943 continue;
4944 }
4945 let root = package.manifest_path.parent().ok_or_else(|| {
4946 invalid_metadata(&format!(
4947 "package `{}` manifest has no parent",
4948 package.name
4949 ))
4950 })?;
4951 let relative_manifest = package
4952 .manifest_path
4953 .strip_prefix(workspace_root)
4954 .unwrap_or(&package.manifest_path);
4955 let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
4956 .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
4957 inputs.push((label, root.to_owned()));
4958 }
4959 Ok(())
4960}
4961
4962fn append_additional_inputs(
4963 inputs: &mut Vec<(PathBuf, PathBuf)>,
4964 spec: &WasmBuildSpec,
4965 workspace_root: &Path,
4966) {
4967 for additional in &spec.additional_inputs {
4968 let path = if additional.is_absolute() {
4969 additional.clone()
4970 } else {
4971 workspace_root.join(additional)
4972 };
4973 inputs.push((PathBuf::from("additional").join(additional), path));
4974 }
4975}
4976
4977fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
4978 let mut exclusions = vec![
4979 spec.target_dir.clone(),
4980 spec.workspace_root.join("target"),
4981 spec.workspace_root.join(".git"),
4982 ];
4983 if let Some(shared_target) = shared_incremental_target(spec) {
4984 exclusions.push(shared_target);
4985 }
4986 for (_, path) in inputs {
4987 if path.is_dir() {
4988 exclusions.push(path.join("target"));
4989 exclusions.push(path.join(".git"));
4990 }
4991 }
4992 exclusions
4993}
4994
4995fn validate_shared_incremental_target_boundary(
4996 spec: &WasmBuildSpec,
4997 inputs: &[(PathBuf, PathBuf)],
4998) -> Result<(), WasmBuildError> {
4999 let Some(shared_target) = shared_incremental_target(spec) else {
5000 return Ok(());
5001 };
5002 let shared_target =
5003 canonicalize_allow_missing(&shared_target).map_err(|source| WasmBuildError::Io {
5004 operation: "resolve shared incremental Cargo target boundary",
5005 path: shared_target.clone(),
5006 source,
5007 })?;
5008 let exact_entries = spec.target_dir.join(".ic-testkit/wasm-targets");
5009 let exact_entries =
5010 canonicalize_allow_missing(&exact_entries).map_err(|source| WasmBuildError::Io {
5011 operation: "resolve exact Wasm cache boundary",
5012 path: exact_entries,
5013 source,
5014 })?;
5015 if shared_target.starts_with(&exact_entries)
5018 || (exact_entries.starts_with(&shared_target)
5019 && !exact_entries.starts_with(shared_target.join(".ic-testkit")))
5020 {
5021 return Err(WasmBuildError::InvalidSpec {
5022 message: "shared incremental target must not overlap removable exact Wasm cache state"
5023 .to_owned(),
5024 });
5025 }
5026 let resolved_inputs = inputs
5027 .iter()
5028 .map(|(_, input)| {
5029 let canonical = input.canonicalize().map_err(|source| WasmBuildError::Io {
5030 operation: "resolve Cargo input boundary",
5031 path: input.clone(),
5032 source,
5033 })?;
5034 let metadata = fs::metadata(&canonical).map_err(|source| WasmBuildError::Io {
5035 operation: "inspect Cargo input boundary",
5036 path: canonical.clone(),
5037 source,
5038 })?;
5039 Ok((canonical, metadata.is_dir()))
5040 })
5041 .collect::<Result<Vec<_>, WasmBuildError>>()?;
5042 let safe_generated_roots = std::iter::once(spec.target_dir.clone())
5043 .chain(std::iter::once(spec.workspace_root.join("target")))
5044 .chain(
5045 inputs
5046 .iter()
5047 .filter(|(_, path)| path.is_dir())
5048 .map(|(_, path)| path.join("target")),
5049 )
5050 .filter_map(|path| canonicalize_allow_missing(&path).ok())
5051 .filter(|root| {
5052 !resolved_inputs
5053 .iter()
5054 .any(|(input, _is_directory)| input.starts_with(root))
5055 })
5056 .collect::<Vec<_>>();
5057 if safe_generated_roots
5058 .iter()
5059 .any(|root| shared_target.starts_with(root))
5060 {
5061 return Ok(());
5062 }
5063
5064 for (input, is_directory) in resolved_inputs {
5065 if shared_target == input
5066 || (is_directory && shared_target.starts_with(&input))
5067 || input.starts_with(&shared_target)
5068 {
5069 return Err(WasmBuildError::InvalidSpec {
5070 message: format!(
5071 "shared incremental target {} must not overlap exact Cargo inputs unless it is inside a generated target directory",
5072 shared_target.display()
5073 ),
5074 });
5075 }
5076 }
5077 Ok(())
5078}
5079
5080pub(super) fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
5081 let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
5082 return None;
5083 };
5084 Some(if target_dir.is_absolute() {
5085 target_dir.clone()
5086 } else {
5087 spec.workspace_root.join(target_dir)
5088 })
5089}
5090
5091fn shared_incremental_target_exists(
5092 spec: &WasmBuildSpec,
5093 operation: &'static str,
5094) -> Result<bool, WasmBuildError> {
5095 let target_dir =
5096 shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
5097 message: "shared incremental target is not configured".to_owned(),
5098 })?;
5099 match fs::symlink_metadata(&target_dir) {
5100 Ok(metadata) if metadata.is_dir() => Ok(true),
5101 Ok(_) => Err(WasmBuildError::InvalidSpec {
5102 message: format!(
5103 "shared incremental Cargo target {} must be a directory",
5104 target_dir.display()
5105 ),
5106 }),
5107 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
5108 Err(source) => Err(WasmBuildError::Io {
5109 operation,
5110 path: target_dir,
5111 source,
5112 }),
5113 }
5114}
5115
5116fn effective_environment(spec: &WasmBuildSpec) -> BTreeMap<OsString, Option<OsString>> {
5117 let mut names = spec.inherited_env.clone();
5118 names.extend(AUTOMATIC_ENVIRONMENT.iter().map(OsString::from));
5119 let mut environment = names
5120 .into_iter()
5121 .map(|name| {
5122 let value = std::env::var_os(&name);
5123 (name, value)
5124 })
5125 .collect::<BTreeMap<_, _>>();
5126 for (key, value) in &spec.extra_env {
5127 environment.insert(key.clone(), Some(value.clone()));
5128 }
5129 environment
5130}
5131
5132fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
5133 for (key, value) in &spec.extra_env {
5134 command.env(key, value);
5135 }
5136}
5137
5138fn run_cargo_build(
5139 spec: &WasmBuildSpec,
5140 build_target_dir: &Path,
5141 progress: &mut ProgressReporter<'_>,
5142) -> Result<(), WasmBuildError> {
5143 let command = cargo_build_command(spec, build_target_dir)?;
5144 communicate_cargo_build(spec, build_target_dir, command, progress)
5145}
5146
5147fn cargo_build_command(
5148 spec: &WasmBuildSpec,
5149 build_target_dir: &Path,
5150) -> Result<Command, WasmBuildError> {
5151 let absolute_target_dir =
5152 canonicalize_allow_missing(build_target_dir).map_err(|source| WasmBuildError::Io {
5153 operation: "resolve Cargo build target directory",
5154 path: build_target_dir.to_owned(),
5155 source,
5156 })?;
5157 let mut command = Command::new(&spec.cargo_program);
5158 command
5159 .current_dir(&spec.workspace_root)
5160 .env("CARGO_TARGET_DIR", absolute_target_dir)
5161 .arg("build");
5162 if let Some(example) = &spec.example {
5163 command.args(["--example", example]);
5164 } else {
5165 command.arg("--lib");
5166 }
5167 command.args(["--target", &spec.target]).args(
5168 spec.cargo_profile_args
5169 .iter()
5170 .filter(|argument| argument.as_os_str() != OsStr::new("--lib")),
5171 );
5172 apply_command_environment(&mut command, spec);
5173 for package in &spec.packages {
5174 command.args(["-p", package]);
5175 }
5176
5177 Ok(command)
5178}
5179
5180fn communicate_cargo_build(
5181 spec: &WasmBuildSpec,
5182 build_target_dir: &Path,
5183 mut command: Command,
5184 progress: &mut ProgressReporter<'_>,
5185) -> Result<(), WasmBuildError> {
5186 command.stdout(Stdio::piped()).stderr(Stdio::piped());
5189 let started = Instant::now();
5190 let observed = progress.is_observed();
5191 if !observed {
5192 command.stdin(Stdio::null());
5194 }
5195 let mut child = if observed {
5196 OwnedChild::spawn(&mut command)
5197 } else {
5198 OwnedChild::spawn_direct(&mut command)
5199 }
5200 .map_err(|source| WasmBuildError::CommandSpawn {
5201 phase: WasmBuildPhase::CargoBuild,
5202 program: spec.cargo_program.clone(),
5203 source,
5204 })?;
5205 progress.emit(WasmBuildProgressEvent::CargoStarted {
5206 target_dir: build_target_dir.to_owned(),
5207 });
5208 let progress = RefCell::new(progress);
5209 let result = communicate_child_with_observer(
5210 &mut child,
5211 None,
5212 OutputLimits {
5213 stdout: OutputLimit::Truncate(CARGO_DIAGNOSTIC_BYTES),
5216 stderr: OutputLimit::Truncate(CARGO_DIAGNOSTIC_BYTES),
5217 timeout: None,
5218 },
5219 SuccessfulExit::Cleanup,
5220 || {
5221 progress
5222 .borrow_mut()
5223 .emit_heartbeat_if_due(WasmBuildProgressPhase::CargoBuild, started.elapsed());
5224 false
5225 },
5226 |stream, bytes| {
5227 let mut progress = progress.borrow_mut();
5228 if progress.config.emit_cargo_output {
5229 progress.emit(WasmBuildProgressEvent::CargoOutput {
5230 stream: match stream {
5231 OutputStream::Stdout => WasmBuildOutputStream::Stdout,
5232 OutputStream::Stderr => WasmBuildOutputStream::Stderr,
5233 },
5234 bytes: bytes.to_vec(),
5235 });
5236 }
5237 Ok(())
5238 },
5239 );
5240 let status = match &result {
5241 Ok(evidence) => Some(evidence.status),
5242 Err(error) => error.evidence().and_then(|evidence| evidence.status),
5243 };
5244 if let Some(status) = status {
5245 progress
5246 .borrow_mut()
5247 .emit(WasmBuildProgressEvent::CargoFinished {
5248 success: status.success(),
5249 code: status.code(),
5250 elapsed: started.elapsed(),
5251 });
5252 }
5253 let evidence = match result {
5254 Ok(evidence) => evidence,
5255 Err(error) => {
5256 return Err(WasmBuildError::Io {
5257 operation: "communicate with cargo build",
5258 path: PathBuf::from(&spec.cargo_program),
5259 source: io::Error::other(error),
5261 });
5262 }
5263 };
5264 let status = evidence.status;
5265 if status.success() {
5266 return Ok(());
5267 }
5268 Err(WasmBuildError::CommandFailed {
5269 phase: WasmBuildPhase::CargoBuild,
5270 status,
5271 stdout: cargo_diagnostic(&evidence.stdout, evidence.stdout_truncated),
5272 stderr: cargo_diagnostic(&evidence.stderr, evidence.stderr_truncated),
5273 })
5274}
5275
5276fn cargo_diagnostic(bytes: &[u8], truncated: bool) -> String {
5277 let mut text = String::from_utf8_lossy(bytes).into_owned();
5278 if truncated {
5279 text.push_str("\n[diagnostic truncated after 1 MiB; enable Cargo output events for the complete stream]\n");
5280 }
5281 text
5282}
5283
5284fn ensure_command_success(
5285 phase: WasmBuildPhase,
5286 output: CompletedExecution,
5287) -> Result<CompletedExecution, WasmBuildError> {
5288 if output.status.success() {
5289 return Ok(output);
5290 }
5291 Err(WasmBuildError::CommandFailed {
5292 phase,
5293 status: output.status,
5294 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
5295 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
5296 })
5297}
5298
5299fn default_library_name(package: &str) -> String {
5300 package.replace('-', "_")
5301}
5302
5303fn expected_artifacts(spec: &WasmBuildSpec, target_dir: &Path) -> Vec<PathBuf> {
5304 spec.packages
5305 .iter()
5306 .map(|package| {
5307 let profile = target_dir.join(&spec.target).join(&spec.profile_target_dir);
5308 spec.example.as_ref().map_or_else(
5309 || profile.join(format!("{}.wasm", default_library_name(package))),
5310 |example| {
5311 profile
5312 .join("examples")
5313 .join(format!("{}.wasm", default_library_name(example)))
5314 },
5315 )
5316 })
5317 .collect()
5318}
5319
5320fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
5321 spec.target_dir
5322 .join(".ic-testkit/wasm-targets")
5323 .join(fingerprint.to_hex())
5324}
5325
5326fn validated_artifact_set(
5327 artifacts: &[PathBuf],
5328 fingerprint: InputDigest,
5329) -> Option<Vec<FileDigest>> {
5330 let header = artifact_stamp_header(fingerprint);
5331 let stamp_len = artifact_stamp_contents(fingerprint, fingerprint).len();
5334 artifacts
5335 .iter()
5336 .map(|artifact| {
5337 let metadata = fs::metadata(artifact).ok()?;
5338 if !metadata.is_file() || metadata.len() == 0 {
5339 return None;
5340 }
5341 let stamp = read_stamp_with_limit(&artifact_stamp_path(artifact), stamp_len).ok()??;
5342 if stamp.len() != stamp_len || !stamp.starts_with(&header) {
5345 return None;
5346 }
5347 let info = digest_file("wasm-artifact-v1", artifact).ok()?;
5348 (stamp == artifact_stamp_contents(fingerprint, info.digest)).then_some(info)
5349 })
5350 .collect()
5351}
5352
5353fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
5354 artifacts
5355 .iter()
5356 .filter(|path| {
5357 fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
5358 })
5359 .cloned()
5360 .collect()
5361}
5362
5363fn artifact_stamp_path(artifact: &Path) -> PathBuf {
5364 let mut name = artifact
5365 .file_name()
5366 .map_or_else(|| OsString::from("artifact"), OsString::from);
5367 name.push(".ic-testkit-build");
5368 artifact.with_file_name(name)
5369}
5370
5371fn artifact_stamp_header(fingerprint: InputDigest) -> String {
5372 format!("{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\n")
5373}
5374
5375fn artifact_stamp_contents(fingerprint: InputDigest, artifact_digest: InputDigest) -> String {
5376 format!(
5377 "{}artifact-sha256:{artifact_digest}\n",
5378 artifact_stamp_header(fingerprint),
5379 )
5380}
5381
5382fn write_artifact_stamp(
5383 artifact: &Path,
5384 fingerprint: InputDigest,
5385 info: &FileDigest,
5386 preserve_matching: bool,
5387) -> Result<(), WasmBuildError> {
5388 let stamp_path = artifact_stamp_path(artifact);
5389 let stamp = artifact_stamp_contents(fingerprint, info.digest);
5390 if preserve_matching && destination_matches_bytes(&stamp_path, stamp.as_bytes()) {
5391 return Ok(());
5392 }
5393 ic_host_fs::durable::write_bytes(&stamp_path, stamp.as_bytes()).map_err(|source| {
5394 WasmBuildError::Io {
5395 operation: "publish Wasm build stamp",
5396 path: stamp_path,
5397 source: io::Error::other(source),
5398 }
5399 })
5400}
5401
5402fn publish_artifact_stamps(
5403 artifacts: &[PathBuf],
5404 fingerprint: InputDigest,
5405) -> Result<Vec<FileDigest>, WasmBuildError> {
5406 let mut info = Vec::with_capacity(artifacts.len());
5407 for artifact in artifacts {
5408 let digest =
5409 digest_file("wasm-artifact-v1", artifact).map_err(|source| WasmBuildError::Io {
5410 operation: "hash built Wasm artifact",
5411 path: artifact.clone(),
5412 source,
5413 })?;
5414 write_artifact_stamp(artifact, fingerprint, &digest, false)?;
5415 info.push(digest);
5416 }
5417 Ok(info)
5418}
5419
5420fn materialize_artifacts(
5421 cached_artifacts: &[PathBuf],
5422 artifacts: &[PathBuf],
5423 fingerprint: InputDigest,
5424 artifact_info: &[FileDigest],
5425 preserve_matching: bool,
5426) -> Result<(), WasmBuildError> {
5427 for ((cached, artifact), info) in cached_artifacts.iter().zip(artifacts).zip(artifact_info) {
5428 materialize_verified_output(
5429 "wasm-artifact-v1",
5430 cached,
5431 artifact,
5432 preserve_matching.then_some(info),
5433 )
5434 .map_err(|source| WasmBuildError::Io {
5435 operation: "publish Wasm artifact",
5436 path: artifact.clone(),
5437 source,
5438 })?;
5439 }
5440 for (artifact, info) in artifacts.iter().zip(artifact_info) {
5443 write_artifact_stamp(artifact, fingerprint, info, preserve_matching)?;
5444 }
5445 Ok(())
5446}
5447
5448fn copy_wasm_artifacts(
5449 source_artifacts: &[PathBuf],
5450 cached_artifacts: &[PathBuf],
5451) -> Result<(), WasmBuildError> {
5452 for (source, cached) in source_artifacts.iter().zip(cached_artifacts) {
5453 copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
5454 operation: "cache shared-incremental Wasm artifact",
5455 path: cached.clone(),
5456 source: source_error,
5457 })?;
5458 }
5459 Ok(())
5460}
5461
5462fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
5463 fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
5464 operation,
5465 path: path.to_owned(),
5466 source,
5467 })
5468}
5469
5470fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
5471 if path.exists() {
5472 inputs.push((PathBuf::from(label), path));
5473 }
5474}
5475
5476fn required_string<'a>(value: &'a Value, field: &str) -> Result<&'a str, String> {
5477 value
5478 .get(field)
5479 .and_then(Value::as_str)
5480 .ok_or_else(|| format!("Cargo metadata field `{field}` is missing"))
5481}
5482
5483fn optional_string<'a>(value: &'a Value, field: &str) -> Result<Option<&'a str>, String> {
5484 match value.get(field) {
5485 None | Some(Value::Null) => Ok(None),
5486 Some(Value::String(value)) => Ok(Some(value)),
5487 Some(_) => Err(format!(
5488 "Cargo metadata field `{field}` is not a string or null"
5489 )),
5490 }
5491}
5492
5493fn invalid_metadata(message: &str) -> WasmBuildError {
5494 WasmBuildError::InvalidMetadata {
5495 message: message.to_owned(),
5496 }
5497}
5498
5499impl WasmBuildError {
5500 fn indicates_input_change(&self) -> bool {
5501 match self {
5502 Self::InputsChangedDuringAcquisition { .. } => true,
5503 Self::FailedBuildCleanup { build_error, .. } => build_error.indicates_input_change(),
5504 _ => false,
5505 }
5506 }
5507}
5508
5509impl std::fmt::Display for WasmBuildPhase {
5510 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5511 formatter.write_str(match self {
5512 Self::CargoMetadata => "cargo metadata",
5513 Self::CargoIdentity => "Cargo identity",
5514 Self::RustcIdentity => "Rust compiler identity",
5515 Self::CargoBuild => "cargo build",
5516 })
5517 }
5518}
5519
5520impl std::fmt::Display for WasmBuildProgressPhase {
5521 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5522 formatter.write_str(match self {
5523 Self::ExactCacheLock => "exact cache lock",
5524 Self::CargoIdentity => "Cargo identity",
5525 Self::RustcIdentity => "Rust compiler identity",
5526 Self::CargoMetadata => "Cargo metadata",
5527 Self::InputDiscovery => "input discovery",
5528 Self::ContentHashing => "content hashing",
5529 Self::SharedTargetLock => "shared target lock",
5530 Self::SharedTargetMaintenance => "shared target maintenance",
5531 Self::CargoBuild => "Cargo build",
5532 Self::ArtifactPublication => "artifact publication",
5533 Self::ExactCacheMaintenance => "exact cache maintenance",
5534 })
5535 }
5536}
5537
5538impl std::fmt::Display for WasmBuildError {
5539 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5540 match self {
5541 Self::InvalidSpec { message } => {
5542 write!(formatter, "invalid Wasm build spec: {message}")
5543 }
5544 Self::Io {
5545 operation,
5546 path,
5547 source,
5548 } => write!(
5549 formatter,
5550 "failed to {operation} at {}: {source}",
5551 path.display()
5552 ),
5553 Self::CommandSpawn {
5554 phase,
5555 program,
5556 source,
5557 } => write!(
5558 formatter,
5559 "failed to launch {phase} using `{}`: {source}",
5560 program.to_string_lossy(),
5561 ),
5562 Self::CommandFailed {
5563 phase,
5564 status,
5565 stdout,
5566 stderr,
5567 } => write!(
5568 formatter,
5569 "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
5570 ),
5571 Self::InvalidMetadata { message } => {
5572 write!(formatter, "invalid Cargo metadata: {message}")
5573 }
5574 Self::InvalidCargoConfiguration { path, message } => write!(
5575 formatter,
5576 "invalid Cargo configuration at {}: {message}",
5577 path.display(),
5578 ),
5579 Self::MissingArtifacts { paths } => write!(
5580 formatter,
5581 "cargo build succeeded without producing: {}",
5582 paths
5583 .iter()
5584 .map(|path| path.display().to_string())
5585 .collect::<Vec<_>>()
5586 .join(", "),
5587 ),
5588 Self::InputsChangedDuringAcquisition { before, after } => write!(
5589 formatter,
5590 "Wasm inputs changed during artifact acquisition: {before} -> {after}",
5591 ),
5592 Self::PreparedInputSnapshotInvalidated => formatter.write_str(
5593 "the prepared Wasm input snapshot was invalidated before artifact publication",
5594 ),
5595 Self::FailedBuildCleanup {
5596 build_error,
5597 path,
5598 source,
5599 } => write!(
5600 formatter,
5601 "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
5602 path.display(),
5603 ),
5604 }
5605 }
5606}
5607
5608impl std::error::Error for WasmBuildError {
5609 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
5610 match self {
5611 Self::Io { source, .. }
5612 | Self::CommandSpawn { source, .. }
5613 | Self::FailedBuildCleanup { source, .. } => Some(source),
5614 _ => None,
5615 }
5616 }
5617}
5618
5619#[cfg(test)]
5620mod tests;