use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
ffi::{OsStr, OsString},
fs::{self, File},
io,
path::{Path, PathBuf},
process::{Child, Command, ExitStatus, Output, Stdio},
sync::mpsc::{self, RecvTimeoutError},
thread,
time::{Duration, Instant, SystemTime},
};
use toml::Value as TomlValue;
use super::{
cache_fs::{
ArtifactCacheMaintenance, ArtifactCachePrunePolicy, ArtifactCachePruneReport, CacheFsError,
cache_entry_last_used, cache_maintenance_due, directory_logical_size,
ensure_cache_directory_tag as ensure_cache_tag, is_sha256_directory, lock_cache_file,
perform_scheduled_cache_maintenance, prune_direct_child_directories,
record_cache_entry_use as record_entry_use, record_cache_maintenance,
remove_path_if_present,
},
digest::{
InputDigest, InputHasher, copy_file_atomic, digest_bytes, digest_file,
digest_labeled_paths, os_bytes, write_atomic,
},
wasm::wasm_path,
};
const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
const AUTOMATIC_ENVIRONMENT: &[&str] = &[
"CARGO_BUILD_RUSTC",
"CARGO_ENCODED_RUSTFLAGS",
"RUSTC",
"RUSTC_WRAPPER",
"RUSTC_WORKSPACE_WRAPPER",
"RUSTFLAGS",
"RUSTUP_TOOLCHAIN",
];
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WasmBuildSpec {
workspace_root: PathBuf,
target_dir: PathBuf,
packages: Vec<String>,
profile_target_dir: String,
cargo_profile_args: Vec<OsString>,
extra_env: BTreeMap<OsString, OsString>,
inherited_env: BTreeSet<OsString>,
additional_inputs: Vec<PathBuf>,
target: String,
cargo_program: OsString,
rustc_program: OsString,
cache_mode: WasmBuildCacheMode,
prune_policy: Option<WasmBuildCachePrunePolicy>,
prune_interval: Option<Duration>,
shared_incremental_maintenance_config: Option<SharedIncrementalTargetMaintenanceConfig>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct SharedIncrementalTargetMaintenanceConfig {
policy: SharedIncrementalTargetPrunePolicy,
minimum_interval: Duration,
}
#[non_exhaustive]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum WasmBuildCacheMode {
Isolated,
SharedIncremental {
target_dir: PathBuf,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum WasmBuildOutcome {
Built(WasmBuildRecord),
Reused(WasmBuildRecord),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WasmBuildRecord {
fingerprint: InputDigest,
input_digest: InputDigest,
artifacts: Vec<PathBuf>,
timings: WasmBuildTimings,
maintenance: Option<WasmBuildCacheMaintenance>,
shared_incremental_maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct WasmBuildTimings {
lock_wait: Duration,
shared_incremental_lock_wait: Option<Duration>,
input_resolution: WasmInputResolutionTimings,
cargo_build: Option<Duration>,
cache_maintenance: Option<Duration>,
total: Duration,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct WasmInputResolutionTimings {
tool_identity: Duration,
cargo_metadata: Duration,
input_discovery: Duration,
content_hashing: Duration,
total: Duration,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CargoBuildInput {
label: PathBuf,
path: PathBuf,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResolvedCargoBuildInputs {
fingerprint: InputDigest,
input_digest: InputDigest,
inputs: Vec<CargoBuildInput>,
exclusions: Vec<PathBuf>,
timings: WasmInputResolutionTimings,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SharedIncrementalTargetInspection {
target_dir: PathBuf,
logical_size_bytes: u64,
last_used: SystemTime,
lock_wait: Duration,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SharedIncrementalTargetPrunePolicy {
max_age: Option<Duration>,
max_size_bytes: Option<u64>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SharedIncrementalTargetMaintenance {
target_dir: PathBuf,
logical_size_bytes_before: u64,
logical_size_bytes_after: u64,
last_used_before: SystemTime,
cleared: bool,
lock_wait: Duration,
maintenance: Duration,
}
#[non_exhaustive]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SharedIncrementalTargetMaintenanceOutcome {
Missing {
target_dir: PathBuf,
},
Skipped {
target_dir: PathBuf,
lock_wait: Duration,
schedule_check: Duration,
},
Performed {
maintenance: SharedIncrementalTargetMaintenance,
schedule_check: Duration,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct WasmBuildProgressConfig {
heartbeat_interval: Option<Duration>,
emit_cargo_output: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WasmBuildOutputStream {
Stdout,
Stderr,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WasmBuildProgressOutcome {
Built,
Reused,
}
#[non_exhaustive]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum WasmBuildProgressEvent {
Started,
InputsResolved {
fingerprint: InputDigest,
input_digest: InputDigest,
elapsed: Duration,
},
CacheMiss {
fingerprint: InputDigest,
},
CacheHit {
fingerprint: InputDigest,
},
SharedTargetLockStarted {
target_dir: PathBuf,
},
SharedTargetLockAcquired {
target_dir: PathBuf,
wait: Duration,
},
SharedTargetMaintenanceStarted {
target_dir: PathBuf,
},
SharedTargetMaintenanceFinished {
outcome: SharedIncrementalTargetMaintenanceOutcome,
},
CargoStarted {
target_dir: PathBuf,
},
CargoOutput {
stream: WasmBuildOutputStream,
bytes: Vec<u8>,
},
CargoHeartbeat {
elapsed: Duration,
},
CargoFinished {
success: bool,
code: Option<i32>,
elapsed: Duration,
},
Finished {
outcome: WasmBuildProgressOutcome,
fingerprint: InputDigest,
elapsed: Duration,
},
}
impl Default for WasmBuildProgressConfig {
fn default() -> Self {
Self {
heartbeat_interval: Some(Duration::from_secs(10)),
emit_cargo_output: true,
}
}
}
impl WasmBuildProgressConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
self.heartbeat_interval = Some(interval);
self
}
#[must_use]
pub const fn without_heartbeats(mut self) -> Self {
self.heartbeat_interval = None;
self
}
#[must_use]
pub const fn with_cargo_output(mut self, emit: bool) -> Self {
self.emit_cargo_output = emit;
self
}
#[must_use]
pub const fn heartbeat_interval(self) -> Option<Duration> {
self.heartbeat_interval
}
#[must_use]
pub const fn emits_cargo_output(self) -> bool {
self.emit_cargo_output
}
}
struct ProgressReporter<'a> {
config: WasmBuildProgressConfig,
observer: Option<&'a mut dyn FnMut(WasmBuildProgressEvent)>,
}
impl ProgressReporter<'_> {
fn silent() -> Self {
Self {
config: WasmBuildProgressConfig {
heartbeat_interval: None,
emit_cargo_output: false,
},
observer: None,
}
}
fn emit(&mut self, event: WasmBuildProgressEvent) {
if let Some(observer) = &mut self.observer {
observer(event);
}
}
const fn is_observed(&self) -> bool {
self.observer.is_some()
}
}
pub type WasmBuildCachePrunePolicy = ArtifactCachePrunePolicy;
pub type WasmBuildCachePruneReport = ArtifactCachePruneReport;
pub type WasmBuildCacheMaintenance = ArtifactCacheMaintenance;
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WasmBuildPhase {
CargoMetadata,
CargoIdentity,
RustcIdentity,
CargoBuild,
}
#[non_exhaustive]
#[derive(Debug)]
pub enum WasmBuildError {
InvalidSpec { message: String },
Io {
operation: &'static str,
path: PathBuf,
source: io::Error,
},
CommandSpawn {
phase: WasmBuildPhase,
program: OsString,
source: io::Error,
},
CommandFailed {
phase: WasmBuildPhase,
status: ExitStatus,
stdout: String,
stderr: String,
},
InvalidMetadata { message: String },
InvalidCargoConfiguration { path: PathBuf, message: String },
MissingArtifacts { paths: Vec<PathBuf> },
InputsChangedDuringBuild {
before: InputDigest,
after: InputDigest,
},
FailedBuildCleanup {
build_error: Box<Self>,
path: PathBuf,
source: io::Error,
},
}
impl WasmBuildSpec {
#[must_use]
pub fn new(
workspace_root: &Path,
target_dir: &Path,
packages: &[&str],
profile_target_dir: &str,
) -> Self {
Self {
workspace_root: workspace_root.to_owned(),
target_dir: target_dir.to_owned(),
packages: packages
.iter()
.map(|package| (*package).to_owned())
.collect(),
profile_target_dir: profile_target_dir.to_owned(),
cargo_profile_args: Vec::new(),
extra_env: BTreeMap::new(),
inherited_env: BTreeSet::new(),
additional_inputs: Vec::new(),
target: DEFAULT_TARGET.to_owned(),
cargo_program: std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()),
rustc_program: std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()),
cache_mode: WasmBuildCacheMode::Isolated,
prune_policy: None,
prune_interval: None,
shared_incremental_maintenance_config: None,
}
}
#[must_use]
pub fn with_cargo_profile_args(mut self, arguments: &[&str]) -> Self {
self.cargo_profile_args = arguments.iter().map(OsString::from).collect();
self
}
#[must_use]
pub fn with_cargo_profile_args_os<I, S>(mut self, arguments: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
self.cargo_profile_args = arguments.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_extra_env(mut self, environment: &[(&str, &str)]) -> Self {
self.extra_env = environment
.iter()
.map(|(key, value)| (OsString::from(key), OsString::from(value)))
.collect();
self
}
#[must_use]
pub fn with_extra_env_os<I, K, V>(mut self, environment: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<OsString>,
V: Into<OsString>,
{
self.extra_env = environment
.into_iter()
.map(|(key, value)| (key.into(), value.into()))
.collect();
self
}
#[must_use]
pub fn with_inherited_env(mut self, names: &[&str]) -> Self {
self.inherited_env.extend(names.iter().map(OsString::from));
self
}
#[must_use]
pub fn with_inherited_env_os<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
self.inherited_env.extend(names.into_iter().map(Into::into));
self
}
#[must_use]
pub fn with_additional_inputs(mut self, paths: &[&str]) -> Self {
self.additional_inputs
.extend(paths.iter().map(PathBuf::from));
self
}
#[must_use]
pub fn with_additional_input_paths<I, P>(mut self, paths: I) -> Self
where
I: IntoIterator<Item = P>,
P: Into<PathBuf>,
{
self.additional_inputs
.extend(paths.into_iter().map(Into::into));
self
}
#[must_use]
pub fn with_target(mut self, target: &str) -> Self {
target.clone_into(&mut self.target);
self
}
#[must_use]
pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
self.cargo_program = program.into();
self
}
#[must_use]
pub fn with_rustc_program(mut self, program: impl Into<OsString>) -> Self {
self.rustc_program = program.into();
self
}
#[must_use]
pub fn with_shared_incremental_target(mut self, target_dir: impl Into<PathBuf>) -> Self {
self.cache_mode = WasmBuildCacheMode::SharedIncremental {
target_dir: target_dir.into(),
};
self
}
#[must_use]
pub const fn with_shared_incremental_target_maintenance_at_most_every(
mut self,
policy: SharedIncrementalTargetPrunePolicy,
minimum_interval: Duration,
) -> Self {
self.shared_incremental_maintenance_config =
Some(SharedIncrementalTargetMaintenanceConfig {
policy,
minimum_interval,
});
self
}
#[must_use]
pub const fn with_prune_policy(mut self, policy: WasmBuildCachePrunePolicy) -> Self {
self.prune_policy = Some(policy);
self.prune_interval = None;
self
}
#[must_use]
pub const fn with_prune_policy_at_most_every(
mut self,
policy: WasmBuildCachePrunePolicy,
minimum_interval: Duration,
) -> Self {
self.prune_policy = Some(policy);
self.prune_interval = Some(minimum_interval);
self
}
#[must_use]
pub fn workspace_root(&self) -> &Path {
&self.workspace_root
}
#[must_use]
pub fn target_dir(&self) -> &Path {
&self.target_dir
}
#[must_use]
pub fn packages(&self) -> &[String] {
&self.packages
}
#[must_use]
pub const fn cache_mode(&self) -> &WasmBuildCacheMode {
&self.cache_mode
}
}
impl WasmBuildOutcome {
#[must_use]
pub const fn record(&self) -> &WasmBuildRecord {
match self {
Self::Built(record) | Self::Reused(record) => record,
}
}
#[must_use]
pub const fn is_reused(&self) -> bool {
matches!(self, Self::Reused(_))
}
}
impl WasmBuildRecord {
#[must_use]
pub const fn fingerprint(&self) -> InputDigest {
self.fingerprint
}
#[must_use]
pub const fn input_digest(&self) -> InputDigest {
self.input_digest
}
#[must_use]
pub fn artifacts(&self) -> &[PathBuf] {
&self.artifacts
}
#[must_use]
pub const fn timings(&self) -> WasmBuildTimings {
self.timings
}
#[must_use]
pub const fn maintenance(&self) -> Option<&WasmBuildCacheMaintenance> {
self.maintenance.as_ref()
}
#[must_use]
pub const fn shared_incremental_maintenance(
&self,
) -> Option<&SharedIncrementalTargetMaintenanceOutcome> {
self.shared_incremental_maintenance.as_ref()
}
}
impl WasmBuildTimings {
#[must_use]
pub const fn lock_wait(self) -> Duration {
self.lock_wait
}
#[must_use]
pub const fn shared_incremental_lock_wait(self) -> Option<Duration> {
self.shared_incremental_lock_wait
}
#[must_use]
pub const fn input_resolution(self) -> Duration {
self.input_resolution.total
}
#[must_use]
pub const fn input_resolution_detail(self) -> WasmInputResolutionTimings {
self.input_resolution
}
#[must_use]
pub const fn cargo_build(self) -> Option<Duration> {
self.cargo_build
}
#[must_use]
pub const fn cache_maintenance(self) -> Option<Duration> {
self.cache_maintenance
}
#[must_use]
pub const fn total(self) -> Duration {
self.total
}
}
impl WasmInputResolutionTimings {
#[must_use]
pub const fn tool_identity(self) -> Duration {
self.tool_identity
}
#[must_use]
pub const fn cargo_metadata(self) -> Duration {
self.cargo_metadata
}
#[must_use]
pub const fn input_discovery(self) -> Duration {
self.input_discovery
}
#[must_use]
pub const fn content_hashing(self) -> Duration {
self.content_hashing
}
#[must_use]
pub const fn total(self) -> Duration {
self.total
}
const fn include(&mut self, other: Self) {
self.tool_identity = self.tool_identity.saturating_add(other.tool_identity);
self.cargo_metadata = self.cargo_metadata.saturating_add(other.cargo_metadata);
self.input_discovery = self.input_discovery.saturating_add(other.input_discovery);
self.content_hashing = self.content_hashing.saturating_add(other.content_hashing);
self.total = self.total.saturating_add(other.total);
}
}
impl CargoBuildInput {
#[must_use]
pub fn label(&self) -> &Path {
&self.label
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
impl ResolvedCargoBuildInputs {
#[must_use]
pub const fn fingerprint(&self) -> InputDigest {
self.fingerprint
}
#[must_use]
pub const fn input_digest(&self) -> InputDigest {
self.input_digest
}
#[must_use]
pub fn inputs(&self) -> &[CargoBuildInput] {
&self.inputs
}
#[must_use]
pub fn exclusions(&self) -> &[PathBuf] {
&self.exclusions
}
#[must_use]
pub const fn timings(&self) -> WasmInputResolutionTimings {
self.timings
}
pub fn is_current(&self, spec: &WasmBuildSpec) -> Result<bool, WasmBuildError> {
resolve_cargo_build_inputs(spec).map(|current| current.fingerprint == self.fingerprint)
}
pub fn is_content_current(&self) -> Result<bool, WasmBuildError> {
self.current_input_digest()
.map(|current| current == self.input_digest)
}
pub(super) fn current_input_digest(&self) -> Result<InputDigest, WasmBuildError> {
let inputs = self
.inputs
.iter()
.map(|input| (input.label.clone(), input.path.clone()))
.collect::<Vec<_>>();
digest_labeled_paths("wasm-source-inputs-v1", &inputs, &self.exclusions).map_err(|source| {
WasmBuildError::Io {
operation: "rehash resolved Cargo build inputs",
path: self
.inputs
.first()
.map_or_else(PathBuf::new, |input| input.path.clone()),
source,
}
})
}
}
impl SharedIncrementalTargetInspection {
#[must_use]
pub fn target_dir(&self) -> &Path {
&self.target_dir
}
#[must_use]
pub const fn logical_size_bytes(&self) -> u64 {
self.logical_size_bytes
}
#[must_use]
pub const fn last_used(&self) -> SystemTime {
self.last_used
}
#[must_use]
pub const fn lock_wait(&self) -> Duration {
self.lock_wait
}
}
impl SharedIncrementalTargetPrunePolicy {
#[must_use]
pub const fn new() -> Self {
Self {
max_age: None,
max_size_bytes: None,
}
}
#[must_use]
pub const fn with_max_age(mut self, max_age: Duration) -> Self {
self.max_age = Some(max_age);
self
}
#[must_use]
pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
self.max_size_bytes = Some(bytes);
self
}
#[must_use]
pub const fn max_age(self) -> Option<Duration> {
self.max_age
}
#[must_use]
pub const fn max_size_bytes(self) -> Option<u64> {
self.max_size_bytes
}
fn maintenance_identity(self) -> String {
format!(
"age={:?};size={:?}",
self.max_age.map(|duration| duration.as_nanos()),
self.max_size_bytes
)
}
}
impl SharedIncrementalTargetMaintenance {
#[must_use]
pub fn target_dir(&self) -> &Path {
&self.target_dir
}
#[must_use]
pub const fn logical_size_bytes_before(&self) -> u64 {
self.logical_size_bytes_before
}
#[must_use]
pub const fn logical_size_bytes_after(&self) -> u64 {
self.logical_size_bytes_after
}
#[must_use]
pub const fn last_used_before(&self) -> SystemTime {
self.last_used_before
}
#[must_use]
pub const fn was_cleared(&self) -> bool {
self.cleared
}
#[must_use]
pub const fn lock_wait(&self) -> Duration {
self.lock_wait
}
#[must_use]
pub const fn maintenance(&self) -> Duration {
self.maintenance
}
}
impl std::fmt::Display for SharedIncrementalTargetMaintenance {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"target={} action={} bytes={}=>{} lock={:?} maintenance={:?}",
self.target_dir.display(),
if self.cleared { "cleared" } else { "retained" },
self.logical_size_bytes_before,
self.logical_size_bytes_after,
self.lock_wait,
self.maintenance,
)
}
}
impl SharedIncrementalTargetMaintenanceOutcome {
#[must_use]
pub fn target_dir(&self) -> &Path {
match self {
Self::Missing { target_dir } | Self::Skipped { target_dir, .. } => target_dir,
Self::Performed { maintenance, .. } => maintenance.target_dir(),
}
}
#[must_use]
pub const fn maintenance(&self) -> Option<&SharedIncrementalTargetMaintenance> {
match self {
Self::Performed { maintenance, .. } => Some(maintenance),
Self::Missing { .. } | Self::Skipped { .. } => None,
}
}
#[must_use]
pub const fn was_performed(&self) -> bool {
matches!(self, Self::Performed { .. })
}
#[must_use]
pub const fn lock_wait(&self) -> Option<Duration> {
match self {
Self::Missing { .. } => None,
Self::Skipped { lock_wait, .. } => Some(*lock_wait),
Self::Performed { maintenance, .. } => Some(maintenance.lock_wait()),
}
}
#[must_use]
pub const fn schedule_check(&self) -> Option<Duration> {
match self {
Self::Missing { .. } => None,
Self::Skipped { schedule_check, .. } | Self::Performed { schedule_check, .. } => {
Some(*schedule_check)
}
}
}
}
impl std::fmt::Display for SharedIncrementalTargetMaintenanceOutcome {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Missing { target_dir } => {
write!(formatter, "target={} action=missing", target_dir.display())
}
Self::Skipped {
target_dir,
lock_wait,
schedule_check,
} => write!(
formatter,
"target={} action=skipped lock={lock_wait:?} schedule={schedule_check:?}",
target_dir.display(),
),
Self::Performed {
maintenance,
schedule_check,
} => write!(formatter, "{maintenance} schedule={schedule_check:?}"),
}
}
}
impl std::fmt::Display for WasmBuildTimings {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"total={:?} lock={:?} shared_lock={:?} inputs={:?} cargo={:?} maintenance={:?}",
self.total,
self.lock_wait,
self.shared_incremental_lock_wait,
self.input_resolution.total,
self.cargo_build,
self.cache_maintenance,
)
}
}
impl std::fmt::Display for WasmBuildOutcome {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let state = if self.is_reused() { "reused" } else { "built" };
write!(
formatter,
"{state} fingerprint={} artifacts={} {}",
self.record().fingerprint,
self.record().artifacts.len(),
self.record().timings,
)?;
if let Some(maintenance) = self.record().shared_incremental_maintenance() {
write!(formatter, " shared_maintenance=({maintenance})")?;
}
Ok(())
}
}
pub fn resolve_cargo_build_inputs(
spec: &WasmBuildSpec,
) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
validate_spec(spec)?;
build_fingerprint(spec)
}
pub fn inspect_shared_incremental_target(
spec: &WasmBuildSpec,
) -> Result<Option<SharedIncrementalTargetInspection>, WasmBuildError> {
if !shared_incremental_target_exists(spec, "inspect shared incremental Cargo target")? {
return Ok(None);
}
let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
let logical_size_bytes =
directory_logical_size(&canonical).map_err(|source| WasmBuildError::Io {
operation: "measure shared incremental Cargo target",
path: canonical.clone(),
source,
})?;
let last_used = cache_entry_last_used(&canonical).map_err(|source| WasmBuildError::Io {
operation: "read shared incremental Cargo target use time",
path: canonical.clone(),
source,
})?;
Ok(Some(SharedIncrementalTargetInspection {
target_dir: canonical,
logical_size_bytes,
last_used,
lock_wait,
}))
}
pub fn maintain_shared_incremental_target(
spec: &WasmBuildSpec,
policy: SharedIncrementalTargetPrunePolicy,
) -> Result<Option<SharedIncrementalTargetMaintenance>, WasmBuildError> {
if !shared_incremental_target_exists(
spec,
"inspect shared incremental Cargo target before maintenance",
)? {
return Ok(None);
}
let _ = resolve_cargo_build_inputs(spec)?;
let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
maintain_shared_incremental_target_locked(&canonical, policy, lock_wait).map(Some)
}
pub fn maintain_shared_incremental_target_at_most_every(
spec: &WasmBuildSpec,
policy: SharedIncrementalTargetPrunePolicy,
minimum_interval: Duration,
) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
let target_dir =
shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
message: "shared incremental target is not configured".to_owned(),
})?;
if !shared_incremental_target_exists(
spec,
"inspect shared incremental Cargo target before scheduled maintenance",
)? {
return Ok(SharedIncrementalTargetMaintenanceOutcome::Missing { target_dir });
}
let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
let schedule = schedule_shared_incremental_target_maintenance(
&canonical,
policy,
minimum_interval,
lock_wait,
)?;
let schedule = match schedule {
SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => return Ok(outcome),
SharedIncrementalTargetMaintenanceSchedule::Due(due) => due,
};
let _ = resolve_cargo_build_inputs(spec)?;
perform_due_shared_incremental_target_maintenance(&canonical, policy, lock_wait, schedule)
}
enum SharedIncrementalTargetMaintenanceSchedule {
Skipped(SharedIncrementalTargetMaintenanceOutcome),
Due(DueSharedIncrementalTargetMaintenance),
}
struct DueSharedIncrementalTargetMaintenance {
schedule_root: PathBuf,
maintenance_identity: String,
schedule_check: Duration,
}
fn schedule_shared_incremental_target_maintenance(
canonical: &Path,
policy: SharedIncrementalTargetPrunePolicy,
minimum_interval: Duration,
lock_wait: Duration,
) -> Result<SharedIncrementalTargetMaintenanceSchedule, WasmBuildError> {
let schedule_root = canonical.join(".ic-testkit");
let maintenance_identity = policy.maintenance_identity();
let schedule_started = Instant::now();
let due = cache_maintenance_due(
&schedule_root,
Some(minimum_interval),
&maintenance_identity,
)
.map_err(wasm_cache_fs_error)?;
let schedule_check = schedule_started.elapsed();
if !due {
return Ok(SharedIncrementalTargetMaintenanceSchedule::Skipped(
SharedIncrementalTargetMaintenanceOutcome::Skipped {
target_dir: canonical.to_owned(),
lock_wait,
schedule_check,
},
));
}
Ok(SharedIncrementalTargetMaintenanceSchedule::Due(
DueSharedIncrementalTargetMaintenance {
schedule_root,
maintenance_identity,
schedule_check,
},
))
}
fn perform_due_shared_incremental_target_maintenance(
canonical: &Path,
policy: SharedIncrementalTargetPrunePolicy,
lock_wait: Duration,
due: DueSharedIncrementalTargetMaintenance,
) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
let DueSharedIncrementalTargetMaintenance {
schedule_root,
maintenance_identity,
schedule_check,
} = due;
let maintenance = maintain_shared_incremental_target_locked(canonical, policy, lock_wait)?;
record_cache_maintenance(&schedule_root, &maintenance_identity).map_err(wasm_cache_fs_error)?;
Ok(SharedIncrementalTargetMaintenanceOutcome::Performed {
maintenance,
schedule_check,
})
}
fn maintain_shared_incremental_target_locked(
canonical: &Path,
policy: SharedIncrementalTargetPrunePolicy,
lock_wait: Duration,
) -> Result<SharedIncrementalTargetMaintenance, WasmBuildError> {
let started = Instant::now();
let logical_size_bytes_before =
directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
operation: "measure shared incremental Cargo target before maintenance",
path: canonical.to_owned(),
source,
})?;
let last_used_before =
cache_entry_last_used(canonical).map_err(|source| WasmBuildError::Io {
operation: "read shared incremental Cargo target use time before maintenance",
path: canonical.to_owned(),
source,
})?;
let expired = policy.max_age.is_some_and(|max_age| {
SystemTime::now()
.duration_since(last_used_before)
.is_ok_and(|age| age > max_age)
});
let oversized = policy
.max_size_bytes
.is_some_and(|max_size_bytes| logical_size_bytes_before > max_size_bytes);
let cleared = expired || oversized;
if cleared {
clear_shared_incremental_target_contents(canonical)?;
record_cache_entry_use(canonical)?;
}
let logical_size_bytes_after = if cleared {
directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
operation: "measure shared incremental Cargo target after maintenance",
path: canonical.to_owned(),
source,
})?
} else {
logical_size_bytes_before
};
Ok(SharedIncrementalTargetMaintenance {
target_dir: canonical.to_owned(),
logical_size_bytes_before,
logical_size_bytes_after,
last_used_before,
cleared,
lock_wait,
maintenance: started.elapsed(),
})
}
fn clear_shared_incremental_target_contents(target_dir: &Path) -> Result<(), WasmBuildError> {
let entries = fs::read_dir(target_dir).map_err(|source| WasmBuildError::Io {
operation: "read shared incremental Cargo target for maintenance",
path: target_dir.to_owned(),
source,
})?;
for entry in entries {
let path = entry
.map_err(|source| WasmBuildError::Io {
operation: "read shared incremental Cargo target entry for maintenance",
path: target_dir.to_owned(),
source,
})?
.path();
let preserved = path
.file_name()
.is_some_and(|name| name == ".ic-testkit" || name == "CACHEDIR.TAG");
if !preserved {
remove_path_if_present(&path).map_err(|source| WasmBuildError::Io {
operation: "clear shared incremental Cargo target entry",
path,
source,
})?;
}
}
Ok(())
}
pub fn build_wasm_canisters_cached(
spec: &WasmBuildSpec,
) -> Result<WasmBuildOutcome, WasmBuildError> {
build_wasm_canisters_cached_internal(spec, &mut ProgressReporter::silent())
}
pub fn build_wasm_canisters_cached_with_progress<F>(
spec: &WasmBuildSpec,
config: WasmBuildProgressConfig,
mut observer: F,
) -> Result<WasmBuildOutcome, WasmBuildError>
where
F: FnMut(WasmBuildProgressEvent),
{
if config.heartbeat_interval == Some(Duration::ZERO) {
return Err(WasmBuildError::InvalidSpec {
message: "Wasm build progress heartbeat interval must be greater than zero".to_owned(),
});
}
build_wasm_canisters_cached_internal(
spec,
&mut ProgressReporter {
config,
observer: Some(&mut observer),
},
)
}
fn build_wasm_canisters_cached_internal(
spec: &WasmBuildSpec,
progress: &mut ProgressReporter<'_>,
) -> Result<WasmBuildOutcome, WasmBuildError> {
let total_started = Instant::now();
validate_spec(spec)?;
progress.emit(WasmBuildProgressEvent::Started);
if spec.shared_incremental_maintenance_config.is_some() {
let outcome = build_wasm_canisters_cached_with_scheduled_shared_maintenance(
spec,
total_started,
progress,
)?;
emit_finished_progress(&outcome, progress);
return Ok(outcome);
}
let (cache_lock, first_lock_wait) = lock_wasm_build_cache(&spec.target_dir)?;
ensure_cache_directory_tag(&spec.target_dir)?;
let resolved = resolve_inputs_with_progress(spec, progress)?;
if let Some(outcome) = try_reuse_wasm_artifacts(
spec,
&resolved,
first_lock_wait,
&SharedIncrementalAcquisitionContext::default(),
total_started,
)? {
emit_finished_progress(&outcome, progress);
return Ok(outcome);
}
progress.emit(WasmBuildProgressEvent::CacheMiss {
fingerprint: resolved.fingerprint,
});
let outcome = match &spec.cache_mode {
WasmBuildCacheMode::Isolated => {
let cache_entry = cache_entry_directory(spec, resolved.fingerprint);
build_wasm_cache_miss(
spec,
resolved,
first_lock_wait,
SharedIncrementalAcquisitionContext::default(),
cache_entry,
total_started,
progress,
)
}
WasmBuildCacheMode::SharedIncremental { .. } => {
drop(cache_lock);
let configured_target = shared_incremental_target(spec)
.expect("shared cache mode must resolve a shared Cargo target");
progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted {
target_dir: configured_target,
});
let (shared_lock, shared_lock_wait, shared_target) =
lock_shared_incremental_target(spec)?;
progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
target_dir: shared_target.clone(),
wait: shared_lock_wait,
});
let (_cache_lock, second_lock_wait) = lock_wasm_build_cache(&spec.target_dir)?;
ensure_cache_directory_tag(&spec.target_dir)?;
let mut current = resolve_inputs_with_progress(spec, progress)?;
current.timings.include(resolved.timings);
let lock_wait = first_lock_wait.saturating_add(second_lock_wait);
let shared_incremental = SharedIncrementalAcquisitionContext {
lock_wait: Some(shared_lock_wait),
maintenance: None,
};
if let Some(outcome) = try_reuse_wasm_artifacts(
spec,
¤t,
lock_wait,
&shared_incremental,
total_started,
)? {
emit_finished_progress(&outcome, progress);
return Ok(outcome);
}
let outcome = build_wasm_cache_miss(
spec,
current,
lock_wait,
shared_incremental,
shared_target,
total_started,
progress,
);
drop(shared_lock);
outcome
}
}?;
emit_finished_progress(&outcome, progress);
Ok(outcome)
}
fn build_wasm_canisters_cached_with_scheduled_shared_maintenance(
spec: &WasmBuildSpec,
total_started: Instant,
progress: &mut ProgressReporter<'_>,
) -> Result<WasmBuildOutcome, WasmBuildError> {
let configured_target = shared_incremental_target(spec)
.expect("validated scheduled maintenance must have a shared Cargo target");
progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted {
target_dir: configured_target,
});
let (_shared_lock, shared_lock_wait, shared_target) = lock_shared_incremental_target(spec)?;
progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
target_dir: shared_target.clone(),
wait: shared_lock_wait,
});
let (_cache_lock, lock_wait) = lock_wasm_build_cache(&spec.target_dir)?;
ensure_cache_directory_tag(&spec.target_dir)?;
let resolved = resolve_inputs_with_progress(spec, progress)?;
let shared_maintenance = perform_configured_shared_incremental_target_maintenance(
spec,
&shared_target,
shared_lock_wait,
progress,
)?;
let shared_incremental = SharedIncrementalAcquisitionContext {
lock_wait: Some(shared_lock_wait),
maintenance: Some(shared_maintenance),
};
if let Some(outcome) = try_reuse_wasm_artifacts(
spec,
&resolved,
lock_wait,
&shared_incremental,
total_started,
)? {
return Ok(outcome);
}
progress.emit(WasmBuildProgressEvent::CacheMiss {
fingerprint: resolved.fingerprint,
});
build_wasm_cache_miss(
spec,
resolved,
lock_wait,
shared_incremental,
shared_target,
total_started,
progress,
)
}
fn perform_configured_shared_incremental_target_maintenance(
spec: &WasmBuildSpec,
shared_target: &Path,
lock_wait: Duration,
progress: &mut ProgressReporter<'_>,
) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
let config = spec
.shared_incremental_maintenance_config
.expect("configured shared-target maintenance must have settings");
progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceStarted {
target_dir: shared_target.to_owned(),
});
let schedule = schedule_shared_incremental_target_maintenance(
shared_target,
config.policy,
config.minimum_interval,
lock_wait,
)?;
let outcome = match schedule {
SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => outcome,
SharedIncrementalTargetMaintenanceSchedule::Due(due) => {
perform_due_shared_incremental_target_maintenance(
shared_target,
config.policy,
lock_wait,
due,
)?
}
};
progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceFinished {
outcome: outcome.clone(),
});
Ok(outcome)
}
fn resolve_inputs_with_progress(
spec: &WasmBuildSpec,
progress: &mut ProgressReporter<'_>,
) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
let resolved = build_fingerprint(spec)?;
progress.emit(WasmBuildProgressEvent::InputsResolved {
fingerprint: resolved.fingerprint,
input_digest: resolved.input_digest,
elapsed: resolved.timings.total,
});
Ok(resolved)
}
fn emit_finished_progress(outcome: &WasmBuildOutcome, progress: &mut ProgressReporter<'_>) {
let state = if outcome.is_reused() {
progress.emit(WasmBuildProgressEvent::CacheHit {
fingerprint: outcome.record().fingerprint,
});
WasmBuildProgressOutcome::Reused
} else {
WasmBuildProgressOutcome::Built
};
progress.emit(WasmBuildProgressEvent::Finished {
outcome: state,
fingerprint: outcome.record().fingerprint,
elapsed: outcome.record().timings.total,
});
}
#[derive(Clone, Debug, Default)]
struct SharedIncrementalAcquisitionContext {
lock_wait: Option<Duration>,
maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
}
fn try_reuse_wasm_artifacts(
spec: &WasmBuildSpec,
resolved: &ResolvedCargoBuildInputs,
lock_wait: Duration,
shared_incremental: &SharedIncrementalAcquisitionContext,
total_started: Instant,
) -> Result<Option<WasmBuildOutcome>, WasmBuildError> {
let fingerprint = resolved.fingerprint;
let artifacts = expected_artifacts(spec, &spec.target_dir);
let cache_entry = cache_entry_directory(spec, fingerprint);
if artifact_set_matches(&artifacts, fingerprint) {
record_cache_entry_use_if_present(&cache_entry)?;
return Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
spec,
BuildRecordInput {
fingerprint,
input_digest: resolved.input_digest,
artifacts,
lock_wait,
shared_incremental: shared_incremental.clone(),
input_resolution: resolved.timings,
cargo_build: None,
active_entry: &cache_entry,
},
total_started,
))));
}
let cached_artifacts = expected_artifacts(spec, &cache_entry);
if !artifact_set_matches(&cached_artifacts, fingerprint) {
return Ok(None);
}
materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
record_cache_entry_use(&cache_entry)?;
Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
spec,
BuildRecordInput {
fingerprint,
input_digest: resolved.input_digest,
artifacts,
lock_wait,
shared_incremental: shared_incremental.clone(),
input_resolution: resolved.timings,
cargo_build: None,
active_entry: &cache_entry,
},
total_started,
))))
}
fn build_wasm_cache_miss(
spec: &WasmBuildSpec,
resolved: ResolvedCargoBuildInputs,
lock_wait: Duration,
shared_incremental: SharedIncrementalAcquisitionContext,
cargo_target_dir: PathBuf,
total_started: Instant,
progress: &mut ProgressReporter<'_>,
) -> Result<WasmBuildOutcome, WasmBuildError> {
let fingerprint = resolved.fingerprint;
let mut input_resolution = resolved.timings;
let artifacts = expected_artifacts(spec, &spec.target_dir);
let cache_entry = cache_entry_directory(spec, fingerprint);
remove_directory_if_present(&cache_entry)?;
create_dir_all(
&cache_entry,
"create content-addressed Cargo target directory",
)?;
let incomplete_directory = IncompleteBuildDirectory::new(cache_entry.clone());
let build_result = (|| {
if matches!(
spec.cache_mode,
WasmBuildCacheMode::SharedIncremental { .. }
) {
record_cache_entry_use(&cargo_target_dir)?;
}
let build_started = Instant::now();
run_cargo_build(spec, &cargo_target_dir, progress)?;
let cargo_build = build_started.elapsed();
let built_artifacts = expected_artifacts(spec, &cargo_target_dir);
let missing = missing_artifacts(&built_artifacts);
if !missing.is_empty() {
return Err(WasmBuildError::MissingArtifacts { paths: missing });
}
let verified = resolve_inputs_with_progress(spec, progress)?;
input_resolution.include(verified.timings);
if fingerprint != verified.fingerprint {
return Err(WasmBuildError::InputsChangedDuringBuild {
before: fingerprint,
after: verified.fingerprint,
});
}
let cached_artifacts = expected_artifacts(spec, &cache_entry);
if cargo_target_dir != cache_entry {
copy_wasm_artifacts(&built_artifacts, &cached_artifacts)?;
}
publish_artifact_stamps(&cached_artifacts, fingerprint)?;
materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
record_cache_entry_use(&cache_entry)?;
Ok(WasmBuildOutcome::Built(complete_build_record(
spec,
BuildRecordInput {
fingerprint,
input_digest: resolved.input_digest,
artifacts,
lock_wait,
shared_incremental,
input_resolution,
cargo_build: Some(cargo_build),
active_entry: &cache_entry,
},
total_started,
)))
})();
finish_fingerprint_build(build_result, incomplete_directory)
}
pub fn prune_wasm_build_cache(
target_dir: &Path,
policy: WasmBuildCachePrunePolicy,
) -> Result<WasmBuildCachePruneReport, WasmBuildError> {
let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
ensure_cache_directory_tag(target_dir)?;
prune_wasm_build_cache_locked(target_dir, policy, None)
}
struct BuildRecordInput<'a> {
fingerprint: InputDigest,
input_digest: InputDigest,
artifacts: Vec<PathBuf>,
lock_wait: Duration,
shared_incremental: SharedIncrementalAcquisitionContext,
input_resolution: WasmInputResolutionTimings,
cargo_build: Option<Duration>,
active_entry: &'a Path,
}
fn complete_build_record(
spec: &WasmBuildSpec,
input: BuildRecordInput<'_>,
total_started: Instant,
) -> WasmBuildRecord {
let (maintenance, cache_maintenance) = spec.prune_policy.map_or((None, None), |policy| {
let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
let identity = policy.maintenance_identity();
perform_scheduled_cache_maintenance(&cache_root, spec.prune_interval, &identity, || {
prune_wasm_build_cache_locked(&spec.target_dir, policy, Some(input.active_entry))
.map_err(|error| error.to_string())
})
});
WasmBuildRecord {
fingerprint: input.fingerprint,
input_digest: input.input_digest,
artifacts: input.artifacts,
timings: WasmBuildTimings {
lock_wait: input.lock_wait,
shared_incremental_lock_wait: input.shared_incremental.lock_wait,
input_resolution: input.input_resolution,
cargo_build: input.cargo_build,
cache_maintenance,
total: total_started.elapsed(),
},
maintenance,
shared_incremental_maintenance: input.shared_incremental.maintenance,
}
}
fn prune_wasm_build_cache_locked(
target_dir: &Path,
policy: WasmBuildCachePrunePolicy,
protected_entry: Option<&Path>,
) -> Result<WasmBuildCachePruneReport, WasmBuildError> {
let cache_root = target_dir.join(".ic-testkit/wasm-targets");
prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
.map_err(wasm_cache_fs_error)
}
struct IncompleteBuildDirectory {
path: PathBuf,
armed: bool,
}
impl IncompleteBuildDirectory {
const fn new(path: PathBuf) -> Self {
Self { path, armed: true }
}
fn preserve(mut self) {
self.armed = false;
}
fn cleanup(mut self) -> io::Result<()> {
let result = remove_path_if_present(&self.path);
if result.is_ok() {
self.armed = false;
}
result
}
}
impl Drop for IncompleteBuildDirectory {
fn drop(&mut self) {
if self.armed {
let _ = remove_path_if_present(&self.path);
}
}
}
fn finish_fingerprint_build(
result: Result<WasmBuildOutcome, WasmBuildError>,
incomplete_directory: IncompleteBuildDirectory,
) -> Result<WasmBuildOutcome, WasmBuildError> {
match result {
Ok(outcome) => {
incomplete_directory.preserve();
Ok(outcome)
}
Err(build_error) => {
let path = incomplete_directory.path.clone();
match incomplete_directory.cleanup() {
Ok(()) => Err(build_error),
Err(source) => Err(WasmBuildError::FailedBuildCleanup {
build_error: Box::new(build_error),
path,
source,
}),
}
}
}
}
fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
create_dir_all(target_dir, "create Cargo target directory")?;
let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)
}
fn lock_shared_incremental_target(
spec: &WasmBuildSpec,
) -> Result<(File, Duration, PathBuf), WasmBuildError> {
let target_dir =
shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
message: "shared incremental target is not configured".to_owned(),
})?;
create_dir_all(
&target_dir,
"create shared incremental Cargo target directory",
)?;
ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
let canonical = target_dir
.canonicalize()
.map_err(|source| WasmBuildError::Io {
operation: "resolve shared incremental Cargo target directory",
path: target_dir.clone(),
source,
})?;
let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
let (lock, wait) = lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)?;
Ok((lock, wait, canonical))
}
fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
}
fn record_cache_entry_use_if_present(path: &Path) -> Result<(), WasmBuildError> {
if path.is_dir() {
record_cache_entry_use(path)?;
}
Ok(())
}
fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
record_entry_use(path).map_err(wasm_cache_fs_error)
}
fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
WasmBuildError::Io {
operation: error.operation,
path: error.path,
source: error.source,
}
}
fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
if spec.packages.is_empty() {
return Err(WasmBuildError::InvalidSpec {
message: "at least one Cargo package is required".to_owned(),
});
}
if spec.profile_target_dir.is_empty() {
return Err(WasmBuildError::InvalidSpec {
message: "Cargo profile target directory must not be empty".to_owned(),
});
}
if spec.target.is_empty() {
return Err(WasmBuildError::InvalidSpec {
message: "Cargo compilation target must not be empty".to_owned(),
});
}
if matches!(
&spec.cache_mode,
WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
) {
return Err(WasmBuildError::InvalidSpec {
message: "shared incremental Cargo target directory must not be empty".to_owned(),
});
}
if spec.shared_incremental_maintenance_config.is_some()
&& !matches!(
spec.cache_mode,
WasmBuildCacheMode::SharedIncremental { .. }
)
{
return Err(WasmBuildError::InvalidSpec {
message:
"scheduled shared-target maintenance requires a shared incremental Cargo target"
.to_owned(),
});
}
Ok(())
}
fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
let total_started = Instant::now();
let tool_started = Instant::now();
let cargo_identity = command_identity(
spec,
WasmBuildPhase::CargoIdentity,
&spec.cargo_program,
&["--version", "--verbose"],
)?;
let rustc_program = spec
.extra_env
.get(OsStr::new("RUSTC"))
.unwrap_or(&spec.rustc_program);
let rustc_identity =
command_identity(spec, WasmBuildPhase::RustcIdentity, rustc_program, &["-vV"])?;
let tool_identity = tool_started.elapsed();
let metadata_started = Instant::now();
let metadata = cargo_metadata(spec)?;
let cargo_metadata = metadata_started.elapsed();
let discovery_started = Instant::now();
let inputs = resolve_local_inputs(spec, &metadata)?;
validate_shared_incremental_target_boundary(spec, &inputs)?;
let exclusions = source_exclusions(spec, &inputs);
let input_discovery = discovery_started.elapsed();
let hashing_started = Instant::now();
let input_digest = digest_labeled_paths("wasm-source-inputs-v1", &inputs, &exclusions)
.map_err(|source| WasmBuildError::Io {
operation: "hash Wasm build inputs",
path: spec.workspace_root.clone(),
source,
})?;
let content_hashing = hashing_started.elapsed();
let mut hasher = InputHasher::new(CACHE_FORMAT_VERSION);
let mut packages = spec.packages.clone();
packages.sort();
packages.dedup();
for package in packages {
hasher.field("package", package.as_bytes());
}
hasher.field("target", spec.target.as_bytes());
hasher.field("profile-target-dir", spec.profile_target_dir.as_bytes());
for argument in &spec.cargo_profile_args {
hasher.field("cargo-argument", &os_bytes(argument));
}
for (key, value) in effective_environment(spec) {
hasher.field("environment-key", &os_bytes(&key));
if let Some(value) = value {
hasher.field("environment-value", &os_bytes(&value));
} else {
hasher.field("environment-unset", b"");
}
}
hasher.field("cargo-identity", &cargo_identity);
hasher.field("rustc-identity", &rustc_identity);
hasher.field("source-input-digest", input_digest.as_bytes());
Ok(ResolvedCargoBuildInputs {
fingerprint: hasher.finish(),
input_digest,
inputs: inputs
.into_iter()
.map(|(label, path)| CargoBuildInput { label, path })
.collect(),
exclusions,
timings: WasmInputResolutionTimings {
tool_identity,
cargo_metadata,
input_discovery,
content_hashing,
total: total_started.elapsed(),
},
})
}
fn command_identity(
spec: &WasmBuildSpec,
phase: WasmBuildPhase,
program: &OsStr,
arguments: &[&str],
) -> Result<Vec<u8>, WasmBuildError> {
let mut command = Command::new(program);
command.current_dir(&spec.workspace_root).args(arguments);
apply_command_environment(&mut command, spec);
let output = command
.output()
.map_err(|source| WasmBuildError::CommandSpawn {
phase,
program: program.to_owned(),
source,
})?;
ensure_command_success(phase, output).map(|output| {
let mut identity = output.stdout;
identity.extend_from_slice(&output.stderr);
identity
})
}
fn cargo_metadata(spec: &WasmBuildSpec) -> Result<Value, WasmBuildError> {
let mut command = Command::new(&spec.cargo_program);
command
.current_dir(&spec.workspace_root)
.args(["metadata", "--format-version", "1"]);
for argument in metadata_arguments(&spec.cargo_profile_args) {
command.arg(argument);
}
apply_command_environment(&mut command, spec);
let output = command
.output()
.map_err(|source| WasmBuildError::CommandSpawn {
phase: WasmBuildPhase::CargoMetadata,
program: spec.cargo_program.clone(),
source,
})?;
let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
serde_json::from_slice(&output.stdout).map_err(|error| WasmBuildError::InvalidMetadata {
message: format!("Cargo metadata was not valid JSON: {error}"),
})
}
fn metadata_arguments(arguments: &[OsString]) -> Vec<OsString> {
let mut selected = Vec::new();
let mut arguments = arguments.iter();
while let Some(argument) = arguments.next() {
let argument_text = argument.to_string_lossy();
match argument_text.as_ref() {
"--all-features" | "--no-default-features" | "--locked" | "--offline" | "--frozen" => {
selected.push(argument.clone());
}
"--features" | "-F" | "--filter-platform" => {
selected.push(argument.clone());
if let Some(value) = arguments.next() {
selected.push(value.clone());
}
}
_ if argument_text.starts_with("--features=")
|| argument_text.starts_with("--filter-platform=") =>
{
selected.push(argument.clone());
}
_ => {}
}
}
selected
}
#[derive(Clone)]
struct MetadataPackage {
id: String,
name: String,
version: String,
manifest_path: PathBuf,
is_local: bool,
}
fn resolve_local_inputs(
spec: &WasmBuildSpec,
metadata: &Value,
) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
let packages = metadata_packages(metadata)?;
let mut selected_ids = selected_package_ids(spec, metadata, &packages)?;
let dependencies = metadata_dependencies(metadata)?;
let mut closure = BTreeSet::new();
while let Some(id) = selected_ids.pop_front() {
if !closure.insert(id.clone()) {
continue;
}
if let Some(deps) = dependencies.get(&id) {
selected_ids.extend(deps.iter().cloned());
}
}
let workspace_root = metadata
.get("workspace_root")
.and_then(Value::as_str)
.map_or_else(|| spec.workspace_root.clone(), PathBuf::from);
let mut inputs = workspace_configuration_inputs(spec, &workspace_root)?;
append_package_inputs(&mut inputs, &packages, closure, &workspace_root)?;
append_additional_inputs(&mut inputs, spec, &workspace_root);
Ok(inputs)
}
fn metadata_packages(metadata: &Value) -> Result<HashMap<String, MetadataPackage>, WasmBuildError> {
let packages_value = metadata
.get("packages")
.and_then(Value::as_array)
.ok_or_else(|| invalid_metadata("Cargo metadata has no package array"))?;
let mut packages = HashMap::new();
for value in packages_value {
let package = MetadataPackage {
id: required_string(value, "id")?,
name: required_string(value, "name")?,
version: required_string(value, "version")?,
manifest_path: PathBuf::from(required_string(value, "manifest_path")?),
is_local: value.get("source").is_some_and(Value::is_null),
};
packages.insert(package.id.clone(), package);
}
Ok(packages)
}
fn selected_package_ids(
spec: &WasmBuildSpec,
metadata: &Value,
packages: &HashMap<String, MetadataPackage>,
) -> Result<VecDeque<String>, WasmBuildError> {
let workspace_members = metadata
.get("workspace_members")
.and_then(Value::as_array)
.ok_or_else(|| invalid_metadata("Cargo metadata has no workspace member array"))?
.iter()
.filter_map(Value::as_str)
.collect::<HashSet<_>>();
let mut selected_ids = VecDeque::new();
for requested in &spec.packages {
let matches = packages
.values()
.filter(|package| {
package.name == *requested && workspace_members.contains(package.id.as_str())
})
.map(|package| package.id.clone())
.collect::<Vec<_>>();
match matches.as_slice() {
[id] => selected_ids.push_back(id.clone()),
[] => {
return Err(WasmBuildError::InvalidSpec {
message: format!("Cargo workspace contains no package named `{requested}`"),
});
}
_ => {
return Err(WasmBuildError::InvalidSpec {
message: format!("Cargo workspace package name `{requested}` is ambiguous"),
});
}
}
}
Ok(selected_ids)
}
fn metadata_dependencies(metadata: &Value) -> Result<HashMap<String, Vec<String>>, WasmBuildError> {
let mut dependencies = HashMap::<String, Vec<String>>::new();
let nodes = metadata
.pointer("/resolve/nodes")
.and_then(Value::as_array)
.ok_or_else(|| invalid_metadata("Cargo metadata has no resolved dependency nodes"))?;
for node in nodes {
let id = required_string(node, "id")?;
let deps = node
.get("deps")
.and_then(Value::as_array)
.ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
.iter()
.map(|dependency| required_string(dependency, "pkg"))
.collect::<Result<Vec<_>, _>>()?;
dependencies.insert(id, deps);
}
Ok(dependencies)
}
fn workspace_configuration_inputs(
spec: &WasmBuildSpec,
workspace_root: &Path,
) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
let mut inputs = Vec::new();
add_if_present(
&mut inputs,
"workspace/Cargo.toml",
workspace_root.join("Cargo.toml"),
);
add_if_present(
&mut inputs,
"workspace/Cargo.lock",
workspace_root.join("Cargo.lock"),
);
add_if_present(
&mut inputs,
"workspace/rust-toolchain.toml",
workspace_root.join("rust-toolchain.toml"),
);
add_if_present(
&mut inputs,
"workspace/rust-toolchain",
workspace_root.join("rust-toolchain"),
);
append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
Ok(inputs)
}
fn append_cargo_configuration_inputs(
inputs: &mut Vec<(PathBuf, PathBuf)>,
spec: &WasmBuildSpec,
workspace_root: &Path,
) -> Result<(), WasmBuildError> {
let invocation_root =
spec.workspace_root
.canonicalize()
.map_err(|source| WasmBuildError::Io {
operation: "resolve Cargo invocation directory",
path: spec.workspace_root.clone(),
source,
})?;
let canonical_workspace =
workspace_root
.canonicalize()
.map_err(|source| WasmBuildError::Io {
operation: "resolve Cargo workspace directory",
path: workspace_root.to_owned(),
source,
})?;
let mut roots = invocation_root
.ancestors()
.filter_map(|directory| effective_cargo_config(&directory.join(".cargo")))
.collect::<Vec<_>>();
if let Some(cargo_home) = effective_cargo_home(spec, &invocation_root)
&& let Some(config) = effective_cargo_config(&cargo_home)
{
roots.push(config);
}
let mut visited = BTreeSet::new();
for config in roots {
append_cargo_configuration_tree(
inputs,
&config,
&canonical_workspace,
&mut visited,
false,
)?;
}
Ok(())
}
fn effective_cargo_config(directory: &Path) -> Option<PathBuf> {
let extensionless = directory.join("config");
if extensionless.exists() {
return Some(extensionless);
}
let toml = directory.join("config.toml");
toml.exists().then_some(toml)
}
fn effective_cargo_home(spec: &WasmBuildSpec, invocation_root: &Path) -> Option<PathBuf> {
if let Some(cargo_home) = command_environment_value(spec, "CARGO_HOME") {
let cargo_home = PathBuf::from(cargo_home);
return Some(if cargo_home.is_absolute() {
cargo_home
} else {
invocation_root.join(cargo_home)
});
}
default_home_directory(spec).map(|home| {
let home = if home.is_absolute() {
home
} else {
invocation_root.join(home)
};
home.join(".cargo")
})
}
#[cfg(windows)]
fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
command_environment_value(spec, "USERPROFILE")
.or_else(|| command_environment_value(spec, "HOME"))
.map(PathBuf::from)
}
#[cfg(not(windows))]
fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
command_environment_value(spec, "HOME").map(PathBuf::from)
}
fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
spec.extra_env
.get(OsStr::new(name))
.cloned()
.or_else(|| std::env::var_os(name))
}
fn append_cargo_configuration_tree(
inputs: &mut Vec<(PathBuf, PathBuf)>,
config: &Path,
workspace_root: &Path,
visited: &mut BTreeSet<PathBuf>,
optional: bool,
) -> Result<(), WasmBuildError> {
let canonical = match config.canonicalize() {
Ok(canonical) => canonical,
Err(error) if optional && error.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(source) => {
return Err(WasmBuildError::Io {
operation: "resolve Cargo configuration",
path: config.to_owned(),
source,
});
}
};
if !visited.insert(canonical.clone()) {
return Ok(());
}
let contents = fs::read_to_string(&canonical).map_err(|source| WasmBuildError::Io {
operation: "read Cargo configuration",
path: canonical.clone(),
source,
})?;
let configuration = toml::from_str::<TomlValue>(&contents).map_err(|error| {
WasmBuildError::InvalidCargoConfiguration {
path: canonical.clone(),
message: error.to_string(),
}
})?;
inputs.push((
cargo_configuration_label(&canonical, workspace_root),
canonical.clone(),
));
let Some(include) = configuration.get("include") else {
return Ok(());
};
let parent = canonical
.parent()
.ok_or_else(|| WasmBuildError::InvalidCargoConfiguration {
path: canonical.clone(),
message: "configuration path has no parent directory".to_owned(),
})?;
for (included, optional) in cargo_configuration_includes(include, &canonical)? {
let included = if included.is_absolute() {
included
} else {
parent.join(included)
};
append_cargo_configuration_tree(inputs, &included, workspace_root, visited, optional)?;
}
Ok(())
}
fn cargo_configuration_includes(
include: &TomlValue,
config: &Path,
) -> Result<Vec<(PathBuf, bool)>, WasmBuildError> {
let values = match include {
TomlValue::Array(values) => values.as_slice(),
value => std::slice::from_ref(value),
};
values
.iter()
.map(|value| match value {
TomlValue::String(path) => Ok((PathBuf::from(path), false)),
TomlValue::Table(table) => {
let path = table
.get("path")
.and_then(TomlValue::as_str)
.ok_or_else(|| {
invalid_cargo_configuration(
config,
"Cargo configuration include table requires a string `path`",
)
})?;
let optional = table
.get("optional")
.map(|value| {
value.as_bool().ok_or_else(|| {
invalid_cargo_configuration(
config,
"Cargo configuration include `optional` must be a boolean",
)
})
})
.transpose()?
.unwrap_or(false);
Ok((PathBuf::from(path), optional))
}
_ => Err(invalid_cargo_configuration(
config,
"Cargo configuration `include` must contain paths or include tables",
)),
})
.collect()
}
fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
if let Ok(relative) = config.strip_prefix(workspace_root) {
return PathBuf::from("cargo-config/workspace").join(relative);
}
let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
PathBuf::from("cargo-config/external").join(location.to_hex())
}
fn invalid_cargo_configuration(path: &Path, message: &str) -> WasmBuildError {
WasmBuildError::InvalidCargoConfiguration {
path: path.to_owned(),
message: message.to_owned(),
}
}
fn append_package_inputs(
inputs: &mut Vec<(PathBuf, PathBuf)>,
packages: &HashMap<String, MetadataPackage>,
closure: BTreeSet<String>,
workspace_root: &Path,
) -> Result<(), WasmBuildError> {
for id in closure {
let Some(package) = packages.get(&id) else {
return Err(invalid_metadata(&format!(
"resolved package `{id}` is missing"
)));
};
if !package.is_local {
continue;
}
let root = package.manifest_path.parent().ok_or_else(|| {
invalid_metadata(&format!(
"package `{}` manifest has no parent",
package.name
))
})?;
let relative_manifest = package
.manifest_path
.strip_prefix(workspace_root)
.unwrap_or(&package.manifest_path);
let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
.join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
inputs.push((label, root.to_owned()));
}
Ok(())
}
fn append_additional_inputs(
inputs: &mut Vec<(PathBuf, PathBuf)>,
spec: &WasmBuildSpec,
workspace_root: &Path,
) {
for additional in &spec.additional_inputs {
let path = if additional.is_absolute() {
additional.clone()
} else {
workspace_root.join(additional)
};
inputs.push((PathBuf::from("additional").join(additional), path));
}
}
fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
let mut exclusions = vec![
spec.target_dir.clone(),
spec.workspace_root.join("target"),
spec.workspace_root.join(".git"),
];
if let Some(shared_target) = shared_incremental_target(spec) {
exclusions.push(shared_target);
}
for (_, path) in inputs {
if path.is_dir() {
exclusions.push(path.join("target"));
exclusions.push(path.join(".git"));
}
}
exclusions
}
fn validate_shared_incremental_target_boundary(
spec: &WasmBuildSpec,
inputs: &[(PathBuf, PathBuf)],
) -> Result<(), WasmBuildError> {
let Some(shared_target) = shared_incremental_target(spec) else {
return Ok(());
};
let shared_target =
canonicalize_allow_missing(&shared_target).map_err(|source| WasmBuildError::Io {
operation: "resolve shared incremental Cargo target boundary",
path: shared_target.clone(),
source,
})?;
let resolved_inputs = inputs
.iter()
.map(|(_, input)| {
let canonical = input.canonicalize().map_err(|source| WasmBuildError::Io {
operation: "resolve Cargo input boundary",
path: input.clone(),
source,
})?;
let metadata = fs::metadata(&canonical).map_err(|source| WasmBuildError::Io {
operation: "inspect Cargo input boundary",
path: canonical.clone(),
source,
})?;
Ok((canonical, metadata.is_dir()))
})
.collect::<Result<Vec<_>, WasmBuildError>>()?;
let safe_generated_roots = std::iter::once(spec.target_dir.clone())
.chain(std::iter::once(spec.workspace_root.join("target")))
.chain(
inputs
.iter()
.filter(|(_, path)| path.is_dir())
.map(|(_, path)| path.join("target")),
)
.filter_map(|path| canonicalize_allow_missing(&path).ok())
.filter(|root| {
!resolved_inputs
.iter()
.any(|(input, _is_directory)| input.starts_with(root))
})
.collect::<Vec<_>>();
if safe_generated_roots
.iter()
.any(|root| shared_target.starts_with(root))
{
return Ok(());
}
for (input, is_directory) in resolved_inputs {
if shared_target == input
|| (is_directory && shared_target.starts_with(&input))
|| input.starts_with(&shared_target)
{
return Err(WasmBuildError::InvalidSpec {
message: format!(
"shared incremental target {} must not overlap exact Cargo inputs unless it is inside a generated target directory",
shared_target.display()
),
});
}
}
Ok(())
}
fn canonicalize_allow_missing(path: &Path) -> io::Result<PathBuf> {
let absolute = if path.is_absolute() {
path.to_owned()
} else {
std::env::current_dir()?.join(path)
};
let mut unresolved = Vec::<OsString>::new();
let mut existing = absolute.as_path();
loop {
match existing.canonicalize() {
Ok(mut canonical) => {
for component in unresolved.into_iter().rev() {
canonical.push(component);
}
return Ok(canonical);
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
let Some(name) = existing.file_name() else {
return Err(error);
};
unresolved.push(name.to_owned());
existing = existing.parent().ok_or(error)?;
}
Err(error) => return Err(error),
}
}
}
fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
return None;
};
Some(if target_dir.is_absolute() {
target_dir.clone()
} else {
spec.workspace_root.join(target_dir)
})
}
fn shared_incremental_target_exists(
spec: &WasmBuildSpec,
operation: &'static str,
) -> Result<bool, WasmBuildError> {
let target_dir =
shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
message: "shared incremental target is not configured".to_owned(),
})?;
match fs::symlink_metadata(&target_dir) {
Ok(metadata) if metadata.is_dir() => Ok(true),
Ok(_) => Err(WasmBuildError::InvalidSpec {
message: format!(
"shared incremental Cargo target {} must be a directory",
target_dir.display()
),
}),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
Err(source) => Err(WasmBuildError::Io {
operation,
path: target_dir,
source,
}),
}
}
fn effective_environment(spec: &WasmBuildSpec) -> BTreeMap<OsString, Option<OsString>> {
let mut names = spec.inherited_env.clone();
names.extend(AUTOMATIC_ENVIRONMENT.iter().map(OsString::from));
let mut environment = names
.into_iter()
.map(|name| {
let value = std::env::var_os(&name);
(name, value)
})
.collect::<BTreeMap<_, _>>();
for (key, value) in &spec.extra_env {
environment.insert(key.clone(), Some(value.clone()));
}
environment
}
fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
for (key, value) in &spec.extra_env {
command.env(key, value);
}
}
fn run_cargo_build(
spec: &WasmBuildSpec,
build_target_dir: &Path,
progress: &mut ProgressReporter<'_>,
) -> Result<(), WasmBuildError> {
let mut command = Command::new(&spec.cargo_program);
command
.current_dir(&spec.workspace_root)
.env("CARGO_TARGET_DIR", build_target_dir)
.args(["build", "--target", &spec.target])
.args(&spec.cargo_profile_args);
apply_command_environment(&mut command, spec);
for package in &spec.packages {
command.args(["-p", package]);
}
if !progress.is_observed() {
let output = command
.output()
.map_err(|source| WasmBuildError::CommandSpawn {
phase: WasmBuildPhase::CargoBuild,
program: spec.cargo_program.clone(),
source,
})?;
return ensure_command_success(WasmBuildPhase::CargoBuild, output).map(|_| ());
}
run_observed_cargo_build(spec, build_target_dir, command, progress)
}
fn run_observed_cargo_build(
spec: &WasmBuildSpec,
build_target_dir: &Path,
mut command: Command,
progress: &mut ProgressReporter<'_>,
) -> Result<(), WasmBuildError> {
command.stdout(Stdio::piped()).stderr(Stdio::piped());
let started = Instant::now();
let child = command
.spawn()
.map_err(|source| WasmBuildError::CommandSpawn {
phase: WasmBuildPhase::CargoBuild,
program: spec.cargo_program.clone(),
source,
})?;
let mut child = ObservedChild::new(child);
progress.emit(WasmBuildProgressEvent::CargoStarted {
target_dir: build_target_dir.to_owned(),
});
let stdout = child
.child_mut()
.stdout
.take()
.expect("Cargo stdout must be piped");
let stderr = child
.child_mut()
.stderr
.take()
.expect("Cargo stderr must be piped");
let (sender, chunks) = mpsc::channel();
let stdout_sender = sender.clone();
let stdout_reader = thread::spawn(move || {
read_process_output(stdout, WasmBuildOutputStream::Stdout, stdout_sender)
});
let stderr_reader =
thread::spawn(move || read_process_output(stderr, WasmBuildOutputStream::Stderr, sender));
let captured = capture_observed_cargo_output(chunks, progress, started);
let status = child.wait().map_err(|source| WasmBuildError::Io {
operation: "wait for observed cargo build",
path: PathBuf::from(&spec.cargo_program),
source,
})?;
join_output_reader(
stdout_reader,
"read observed cargo stdout",
&spec.cargo_program,
)?;
join_output_reader(
stderr_reader,
"read observed cargo stderr",
&spec.cargo_program,
)?;
let elapsed = started.elapsed();
progress.emit(WasmBuildProgressEvent::CargoFinished {
success: status.success(),
code: status.code(),
elapsed,
});
ensure_command_success(
WasmBuildPhase::CargoBuild,
Output {
status,
stdout: captured.stdout,
stderr: captured.stderr,
},
)
.map(|_| ())
}
struct CapturedProcessOutput {
stdout: Vec<u8>,
stderr: Vec<u8>,
}
fn capture_observed_cargo_output(
chunks: mpsc::Receiver<ProcessOutputChunk>,
progress: &mut ProgressReporter<'_>,
started: Instant,
) -> CapturedProcessOutput {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut last_emitted = Instant::now();
loop {
let message = match progress.config.heartbeat_interval {
Some(interval) => {
let quiet_for = last_emitted.elapsed();
if quiet_for >= interval {
progress.emit(WasmBuildProgressEvent::CargoHeartbeat {
elapsed: started.elapsed(),
});
last_emitted = Instant::now();
None
} else {
match chunks.recv_timeout(interval.saturating_sub(quiet_for)) {
Ok(chunk) => Some(chunk),
Err(RecvTimeoutError::Timeout) => {
progress.emit(WasmBuildProgressEvent::CargoHeartbeat {
elapsed: started.elapsed(),
});
last_emitted = Instant::now();
None
}
Err(RecvTimeoutError::Disconnected) => break,
}
}
}
None => match chunks.recv() {
Ok(chunk) => Some(chunk),
Err(_) => break,
},
};
let Some(chunk) = message else {
continue;
};
match chunk.stream {
WasmBuildOutputStream::Stdout => stdout.extend_from_slice(&chunk.bytes),
WasmBuildOutputStream::Stderr => stderr.extend_from_slice(&chunk.bytes),
}
if progress.config.emit_cargo_output {
progress.emit(WasmBuildProgressEvent::CargoOutput {
stream: chunk.stream,
bytes: chunk.bytes,
});
last_emitted = Instant::now();
}
}
CapturedProcessOutput { stdout, stderr }
}
#[derive(Debug)]
struct ProcessOutputChunk {
stream: WasmBuildOutputStream,
bytes: Vec<u8>,
}
fn read_process_output<R: io::Read>(
mut reader: R,
stream: WasmBuildOutputStream,
sender: mpsc::Sender<ProcessOutputChunk>,
) -> io::Result<()> {
let mut buffer = [0_u8; 8 * 1024];
loop {
let count = reader.read(&mut buffer)?;
if count == 0 {
return Ok(());
}
if sender
.send(ProcessOutputChunk {
stream,
bytes: buffer[..count].to_vec(),
})
.is_err()
{
return Ok(());
}
}
}
fn join_output_reader(
reader: thread::JoinHandle<io::Result<()>>,
operation: &'static str,
cargo_program: &OsStr,
) -> Result<(), WasmBuildError> {
let result = reader.join().map_err(|_| WasmBuildError::Io {
operation,
path: PathBuf::from(cargo_program),
source: io::Error::other("Cargo output reader panicked"),
})?;
result.map_err(|source| WasmBuildError::Io {
operation,
path: PathBuf::from(cargo_program),
source,
})
}
struct ObservedChild(Option<Child>);
impl ObservedChild {
const fn new(child: Child) -> Self {
Self(Some(child))
}
const fn child_mut(&mut self) -> &mut Child {
self.0.as_mut().expect("observed child must be present")
}
fn wait(&mut self) -> io::Result<ExitStatus> {
let status = self.child_mut().wait()?;
self.0.take();
Ok(status)
}
}
impl Drop for ObservedChild {
fn drop(&mut self) {
if let Some(mut child) = self.0.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn ensure_command_success(phase: WasmBuildPhase, output: Output) -> Result<Output, WasmBuildError> {
if output.status.success() {
return Ok(output);
}
Err(WasmBuildError::CommandFailed {
phase,
status: output.status,
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
fn expected_artifacts(spec: &WasmBuildSpec, target_dir: &Path) -> Vec<PathBuf> {
let mut packages = spec.packages.iter().map(String::as_str).collect::<Vec<_>>();
packages.sort_unstable();
packages.dedup();
packages
.into_iter()
.map(|package| {
if spec.target == DEFAULT_TARGET {
wasm_path(target_dir, package, &spec.profile_target_dir)
} else {
target_dir
.join(&spec.target)
.join(&spec.profile_target_dir)
.join(format!("{package}.wasm"))
}
})
.collect()
}
fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
spec.target_dir
.join(".ic-testkit/wasm-targets")
.join(fingerprint.to_hex())
}
fn artifact_set_matches(artifacts: &[PathBuf], fingerprint: InputDigest) -> bool {
artifacts.iter().all(|path| {
fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
&& cache_stamp_matches(path, fingerprint)
})
}
fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
artifacts
.iter()
.filter(|path| {
fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
})
.cloned()
.collect()
}
fn cache_stamp_matches(artifact: &Path, fingerprint: InputDigest) -> bool {
let stamp_path = artifact_stamp_path(artifact);
let Ok(expected) = artifact_stamp_contents(artifact, fingerprint) else {
return false;
};
fs::read_to_string(stamp_path).is_ok_and(|stamp| stamp == expected)
}
fn artifact_stamp_path(artifact: &Path) -> PathBuf {
let mut name = artifact
.file_name()
.map_or_else(|| OsString::from("artifact"), OsString::from);
name.push(".ic-testkit-build");
artifact.with_file_name(name)
}
fn artifact_stamp_contents(artifact: &Path, fingerprint: InputDigest) -> io::Result<String> {
let (_, artifact_digest) = digest_file("wasm-artifact-v1", artifact)?;
Ok(format!(
"{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\nartifact-sha256:{artifact_digest}\n"
))
}
fn publish_artifact_stamps(
artifacts: &[PathBuf],
fingerprint: InputDigest,
) -> Result<(), WasmBuildError> {
for artifact in artifacts {
let stamp_path = artifact_stamp_path(artifact);
let stamp = artifact_stamp_contents(artifact, fingerprint).map_err(|source| {
WasmBuildError::Io {
operation: "hash built Wasm artifact",
path: artifact.clone(),
source,
}
})?;
write_atomic(&stamp_path, stamp.as_bytes()).map_err(|source| WasmBuildError::Io {
operation: "publish Wasm build stamp",
path: stamp_path,
source,
})?;
}
Ok(())
}
fn materialize_artifacts(
cached_artifacts: &[PathBuf],
artifacts: &[PathBuf],
fingerprint: InputDigest,
) -> Result<(), WasmBuildError> {
for (cached, artifact) in cached_artifacts.iter().zip(artifacts) {
copy_file_atomic(cached, artifact).map_err(|source| WasmBuildError::Io {
operation: "publish Wasm artifact",
path: artifact.clone(),
source,
})?;
}
publish_artifact_stamps(artifacts, fingerprint)
}
fn copy_wasm_artifacts(
source_artifacts: &[PathBuf],
cached_artifacts: &[PathBuf],
) -> Result<(), WasmBuildError> {
for (source, cached) in source_artifacts.iter().zip(cached_artifacts) {
copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
operation: "cache shared-incremental Wasm artifact",
path: cached.clone(),
source: source_error,
})?;
}
Ok(())
}
fn remove_directory_if_present(path: &Path) -> Result<(), WasmBuildError> {
remove_path_if_present(path).map_err(|source| WasmBuildError::Io {
operation: "remove incomplete content-addressed Cargo target directory",
path: path.to_owned(),
source,
})
}
fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
operation,
path: path.to_owned(),
source,
})
}
fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
if path.exists() {
inputs.push((PathBuf::from(label), path));
}
}
fn required_string(value: &Value, field: &str) -> Result<String, WasmBuildError> {
value
.get(field)
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| invalid_metadata(&format!("Cargo metadata field `{field}` is missing")))
}
fn invalid_metadata(message: &str) -> WasmBuildError {
WasmBuildError::InvalidMetadata {
message: message.to_owned(),
}
}
impl std::fmt::Display for WasmBuildPhase {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::CargoMetadata => "cargo metadata",
Self::CargoIdentity => "Cargo identity",
Self::RustcIdentity => "Rust compiler identity",
Self::CargoBuild => "cargo build",
})
}
}
impl std::fmt::Display for WasmBuildError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidSpec { message } => {
write!(formatter, "invalid Wasm build spec: {message}")
}
Self::Io {
operation,
path,
source,
} => write!(
formatter,
"failed to {operation} at {}: {source}",
path.display()
),
Self::CommandSpawn {
phase,
program,
source,
} => write!(
formatter,
"failed to launch {phase} using `{}`: {source}",
program.to_string_lossy(),
),
Self::CommandFailed {
phase,
status,
stdout,
stderr,
} => write!(
formatter,
"{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
),
Self::InvalidMetadata { message } => {
write!(formatter, "invalid Cargo metadata: {message}")
}
Self::InvalidCargoConfiguration { path, message } => write!(
formatter,
"invalid Cargo configuration at {}: {message}",
path.display(),
),
Self::MissingArtifacts { paths } => write!(
formatter,
"cargo build succeeded without producing: {}",
paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", "),
),
Self::InputsChangedDuringBuild { before, after } => write!(
formatter,
"Wasm build inputs changed while Cargo was running: {before} -> {after}",
),
Self::FailedBuildCleanup {
build_error,
path,
source,
} => write!(
formatter,
"Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
path.display(),
),
}
}
}
impl std::error::Error for WasmBuildError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. }
| Self::CommandSpawn { source, .. }
| Self::FailedBuildCleanup { source, .. } => Some(source),
_ => None,
}
}
}
#[cfg(test)]
mod tests;