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