From 02a8ec22ee5f4c3c49b77a2022e60d1589d1958b Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Tue, 21 Jul 2026 03:03:09 +0200 Subject: [PATCH 1/2] Recover interrupted runtime bootstrap --- Cargo.lock | 1 + Cargo.toml | 1 + .../install-locations-and-ownership.md | 11 + docs/reference/configuration.md | 3 +- docs/reference/errors.md | 4 + docs/reference/runtime-cli.md | 12 + src/bootstrap_lock.rs | 84 ++++ src/bootstrap_state.rs | 274 +++++++++++++ src/commands.rs | 359 +++++++++++++++--- src/config.rs | 103 ++++- src/exec.rs | 18 +- src/install.rs | 121 ++++-- src/main.rs | 2 + 13 files changed, 911 insertions(+), 82 deletions(-) create mode 100644 src/bootstrap_lock.rs create mode 100644 src/bootstrap_state.rs diff --git a/Cargo.lock b/Cargo.lock index ec28e53..fccf655 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -516,6 +516,7 @@ dependencies = [ "clap", "console", "dirs", + "fs4", "futures", "indicatif", "insta", diff --git a/Cargo.toml b/Cargo.toml index ec576ac..d9f9d95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ clap = { version = "4.6", features = ["derive"] } console = "0.16" dirs = "6" futures = "0.3" +fs4 = "0.13" indicatif = "0.18" miette = { version = "7.6", features = ["fancy"] } rattler = { version = "0.46", default-features = false, features = ["indicatif"] } diff --git a/docs/explanation/install-locations-and-ownership.md b/docs/explanation/install-locations-and-ownership.md index f1f7392..44c3d6f 100644 --- a/docs/explanation/install-locations-and-ownership.md +++ b/docs/explanation/install-locations-and-ownership.md @@ -60,6 +60,7 @@ managed prefix. It records: - schema version +- bootstrap state - display name - install name - metadata filename @@ -68,6 +69,16 @@ It records: - package names Later runtime invocations check that metadata before reusing a prefix. +The metadata file is also the bootstrap `ready` commit. Metadata written by +older conda-ship runtimes without an explicit bootstrap state is treated as +ready when its ownership identity and delegate still validate. + +While bootstrap is running, the runtime holds a lock in the prefix's parent +directory and writes a separate internal `installing` marker inside the prefix. +That marker is positive ownership evidence for automatic in-place recovery. A +later invocation waits for a live bootstrap to release the lock, then checks +the prefix again. If the previous process stopped, recovery reinstalls every +locked package and reruns post-link scripts without deleting the prefix. This ownership file is conda-ship-specific. The runtime also writes standard conda prefix metadata: diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 0cfa83f..5a4bce8 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -239,7 +239,8 @@ runtime name as uppercased `RUNTIME_NAME` plus `_PREFIX`. At bootstrap time, the generated runtime writes a separate prefix metadata file inside the managed prefix. That file is used for ownership checks before later -operations touch the prefix. +operations touch the prefix. It is written last as the durable bootstrap-ready +commit. An internal installing marker is removed only after that commit. The bootstrap also writes standard conda prefix metadata: diff --git a/docs/reference/errors.md b/docs/reference/errors.md index c0dea4f..0014937 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -100,3 +100,7 @@ conda without depending on terminal formatting. `refusing to use unmanaged install path` : The prefix does not contain ownership metadata for this runtime. + +`refusing to use install path with invalid bootstrap state` +: The internal installing marker is malformed or does not belong to this + runtime. conda-ship does not guess ownership of a non-empty prefix. diff --git a/docs/reference/runtime-cli.md b/docs/reference/runtime-cli.md index 4884dfa..59d2710 100644 --- a/docs/reference/runtime-cli.md +++ b/docs/reference/runtime-cli.md @@ -39,6 +39,18 @@ prefix metadata expected by conda tools in `conda-meta/history` and build configured `condarc-file`, and writes the CEP 22 frozen marker only when the build configured `freeze-base = true`. +Bootstrap is serialized with a process lock next to the managed prefix. An +internal `installing` marker identifies an incomplete prefix owned by this +runtime. The runtime metadata file is written after package installation, +post-link scripts, prefix metadata, configured policy, bytecode compilation, +and delegate validation finish. Its `ready` state marks bootstrap complete. + +If bootstrap is interrupted, the next invocation automatically retries only +when that internal marker belongs to the same stamped runtime. Recovery forces +every locked package through Rattler's reinstall path so post-link scripts run +again. It does not delete the prefix, named environments, or unrelated paths. +An unknown non-empty prefix is still refused. + ## Delegate Execution After the prefix is available, every argument belongs to the delegate. The diff --git a/src/bootstrap_lock.rs b/src/bootstrap_lock.rs new file mode 100644 index 0000000..cb40d9c --- /dev/null +++ b/src/bootstrap_lock.rs @@ -0,0 +1,84 @@ +//! Cross-process serialization for automatic runtime bootstrap. + +use std::fs::{File, OpenOptions}; +use std::path::{Path, PathBuf}; + +use fs4::fs_std::FileExt; +use miette::{Context, IntoDiagnostic}; + +use crate::policy; + +pub(crate) struct BootstrapLock { + _file: File, +} + +impl BootstrapLock { + pub(crate) fn acquire(prefix: &Path) -> miette::Result { + let path = path(prefix)?; + let parent = path + .parent() + .ok_or_else(|| miette::miette!("bootstrap lock has no parent directory"))?; + std::fs::create_dir_all(parent) + .into_diagnostic() + .with_context(|| { + format!( + "failed to create bootstrap lock directory at {}", + policy::path_for_display(parent) + ) + })?; + + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .into_diagnostic() + .with_context(|| { + format!( + "failed to open bootstrap lock at {}", + policy::path_for_display(&path) + ) + })?; + file.lock_exclusive().into_diagnostic().with_context(|| { + format!( + "failed to acquire bootstrap lock at {}", + policy::path_for_display(&path) + ) + })?; + Ok(Self { _file: file }) + } +} + +pub(crate) fn path(prefix: &Path) -> miette::Result { + let parent = prefix.parent().ok_or_else(|| { + miette::miette!( + "install path has no parent directory: {}", + policy::path_for_display(prefix) + ) + })?; + let name = prefix.file_name().ok_or_else(|| { + miette::miette!( + "install path has no final component: {}", + policy::path_for_display(prefix) + ) + })?; + Ok(parent.join(format!(".{}.conda-ship.lock", name.to_string_lossy()))) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_lock_is_adjacent_to_prefix() { + let tmp = TempDir::new().unwrap(); + let prefix = tmp.path().join("runtime"); + + let lock = path(&prefix).unwrap(); + + assert_eq!(lock.parent(), prefix.parent()); + assert!(!lock.starts_with(&prefix)); + } +} diff --git a/src/bootstrap_state.rs b/src/bootstrap_state.rs new file mode 100644 index 0000000..257324f --- /dev/null +++ b/src/bootstrap_state.rs @@ -0,0 +1,274 @@ +//! Internal ownership state for an in-progress bootstrap. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use miette::{Context, IntoDiagnostic}; + +use crate::policy; + +const BOOTSTRAP_STATE_SCHEMA_VERSION: u8 = 1; +const BOOTSTRAP_STATE_FILE: &str = ".conda-ship-bootstrap.json"; +const BOOTSTRAP_STATE_TEMP_FILE: &str = ".conda-ship-bootstrap.json.tmp"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum BootstrapPhase { + Installing, + Ready, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct BootstrapState { + schema_version: u8, + state: BootstrapPhase, + display_name: String, + install_name: String, + metadata_file: String, +} + +impl BootstrapState { + fn current(state: BootstrapPhase) -> Self { + Self { + schema_version: BOOTSTRAP_STATE_SCHEMA_VERSION, + state, + display_name: policy::display_name().to_string(), + install_name: policy::install_name().to_string(), + metadata_file: policy::metadata_file().to_string(), + } + } + + pub(crate) fn phase(&self) -> BootstrapPhase { + self.state + } +} + +pub(crate) fn path(prefix: &Path) -> PathBuf { + prefix.join(BOOTSTRAP_STATE_FILE) +} + +fn temporary_path(prefix: &Path) -> PathBuf { + prefix.join(BOOTSTRAP_STATE_TEMP_FILE) +} + +fn remove_regular_file_if_present(path: &Path) -> miette::Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err(miette::miette!( + "bootstrap state is not a regular file: {}", + policy::path_for_display(path) + )) + } + Ok(_) => std::fs::remove_file(path) + .into_diagnostic() + .with_context(|| { + format!( + "failed to remove bootstrap state at {}", + policy::path_for_display(path) + ) + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).into_diagnostic(), + } +} + +fn read_state_file(path: &Path) -> miette::Result> { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(error).into_diagnostic().with_context(|| { + format!( + "failed to inspect bootstrap state at {}", + policy::path_for_display(path) + ) + }); + } + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(miette::miette!( + "bootstrap state is not a regular file: {}", + policy::path_for_display(path) + )); + } + std::fs::read_to_string(path) + .into_diagnostic() + .with_context(|| { + format!( + "failed to read bootstrap state at {}", + policy::path_for_display(path) + ) + }) + .map(Some) +} + +pub(crate) fn read(prefix: &Path) -> miette::Result> { + let path = path(prefix); + let (path, data) = if let Some(data) = read_state_file(&path)? { + (path, data) + } else { + let temporary_path = temporary_path(prefix); + let Some(data) = read_state_file(&temporary_path)? else { + return Ok(None); + }; + (temporary_path, data) + }; + let state = serde_json::from_str(&data) + .into_diagnostic() + .with_context(|| { + format!( + "failed to parse bootstrap state at {}", + policy::path_for_display(&path) + ) + })?; + Ok(Some(state)) +} + +pub(crate) fn write_installing(prefix: &Path) -> miette::Result<()> { + std::fs::create_dir_all(prefix) + .into_diagnostic() + .with_context(|| format!("failed to create {}", policy::path_for_display(prefix)))?; + + let path = path(prefix); + let temporary_path = temporary_path(prefix); + remove_regular_file_if_present(&temporary_path)?; + let mut temporary = std::fs::File::create(&temporary_path) + .into_diagnostic() + .context("failed to create temporary bootstrap state")?; + serde_json::to_writer_pretty( + &mut temporary, + &BootstrapState::current(BootstrapPhase::Installing), + ) + .into_diagnostic() + .context("failed to render bootstrap state")?; + temporary + .write_all(b"\n") + .into_diagnostic() + .context("failed to write bootstrap state")?; + temporary + .sync_all() + .into_diagnostic() + .context("failed to sync bootstrap state")?; + drop(temporary); + + remove_regular_file_if_present(&path)?; + std::fs::rename(&temporary_path, &path) + .into_diagnostic() + .with_context(|| { + format!( + "failed to persist bootstrap state at {}", + policy::path_for_display(&path) + ) + })?; + Ok(()) +} + +pub(crate) fn remove(prefix: &Path) -> miette::Result<()> { + for path in [path(prefix), temporary_path(prefix)] { + remove_regular_file_if_present(&path)?; + } + Ok(()) +} + +pub(crate) fn validate_identity(state: &BootstrapState) -> miette::Result<()> { + if state.schema_version != BOOTSTRAP_STATE_SCHEMA_VERSION { + return Err(miette::miette!( + "unsupported bootstrap state schema version: {}", + state.schema_version + )); + } + if state.display_name != policy::display_name() { + return Err(miette::miette!( + "bootstrap state belongs to {}, not {}", + state.display_name, + policy::display_name() + )); + } + if state.install_name != policy::install_name() { + return Err(miette::miette!( + "bootstrap state install name is {}, expected {}", + state.install_name, + policy::install_name() + )); + } + if state.metadata_file != policy::metadata_file() { + return Err(miette::miette!( + "bootstrap state metadata file is {}, expected {}", + state.metadata_file, + policy::metadata_file() + )); + } + if state.state != BootstrapPhase::Installing { + return Err(miette::miette!( + "bootstrap ownership marker has invalid state: {:?}", + state.state + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_bootstrap_installing_state_roundtrip() { + let tmp = TempDir::new().unwrap(); + + write_installing(tmp.path()).unwrap(); + let installing = read(tmp.path()).unwrap().unwrap(); + validate_identity(&installing).unwrap(); + assert_eq!(installing.phase(), BootstrapPhase::Installing); + } + + #[test] + fn test_bootstrap_state_rejects_another_runtime() { + let mut state = BootstrapState::current(BootstrapPhase::Installing); + state.install_name = "another-runtime".to_string(); + + let error = validate_identity(&state).unwrap_err().to_string(); + + assert!(error.contains("another-runtime")); + assert!(error.contains("expected")); + } + + #[test] + fn test_temporary_installing_state_is_recoverable() { + let tmp = TempDir::new().unwrap(); + let state = BootstrapState::current(BootstrapPhase::Installing); + std::fs::write( + temporary_path(tmp.path()), + serde_json::to_vec_pretty(&state).unwrap(), + ) + .unwrap(); + + let recovered = read(tmp.path()).unwrap().unwrap(); + + validate_identity(&recovered).unwrap(); + assert_eq!(recovered.phase(), BootstrapPhase::Installing); + assert!(!path(tmp.path()).exists()); + } + + #[test] + fn test_ready_marker_is_rejected() { + let state = BootstrapState::current(BootstrapPhase::Ready); + + let error = validate_identity(&state).unwrap_err().to_string(); + + assert!(error.contains("invalid state")); + } + + #[test] + fn test_remove_cleans_final_and_temporary_markers() { + let tmp = TempDir::new().unwrap(); + write_installing(tmp.path()).unwrap(); + std::fs::write(temporary_path(tmp.path()), b"stale").unwrap(); + + remove(tmp.path()).unwrap(); + + assert!(!path(tmp.path()).exists()); + assert!(!temporary_path(tmp.path()).exists()); + } +} diff --git a/src/commands.rs b/src/commands.rs index 4c38e27..bb237ae 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -4,14 +4,18 @@ use std::path::{Path, PathBuf}; use miette::IntoDiagnostic; +use crate::bootstrap_lock::BootstrapLock; +use crate::bootstrap_state::{self, BootstrapPhase}; use crate::config::{ PrefixMetadata, embedded_config, embedded_lock, read_metadata, write_condarc, write_frozen, write_metadata, }; use crate::{constructor_metadata, exec, install, policy}; -pub(crate) fn is_bootstrapped(prefix: &Path) -> bool { - prefix.join("conda-meta").is_dir() +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PrefixDisposition { + Ready, + Bootstrap { reinstall: bool }, } fn is_empty_dir(prefix: &Path) -> miette::Result { @@ -24,10 +28,6 @@ fn is_empty_dir(prefix: &Path) -> miette::Result { .is_none()) } -fn require_managed_prefix(prefix: &Path, action: &str) -> miette::Result<()> { - read_managed_metadata(prefix, action).map(|_| ()) -} - fn read_managed_metadata(prefix: &Path, action: &str) -> miette::Result { let metadata_path = crate::config::metadata_path(prefix); if !metadata_path.is_file() { @@ -45,7 +45,7 @@ fn read_managed_metadata(prefix: &Path, action: &str) -> miette::Result miette::Result miette::Result<()> { - if is_bootstrapped(prefix) { - require_managed_prefix(prefix, "use")?; - return Ok(()); + let _lock = BootstrapLock::acquire(prefix)?; + let reinstall = match prefix_disposition(prefix)? { + PrefixDisposition::Ready => return Ok(()), + PrefixDisposition::Bootstrap { reinstall } => reinstall, + }; + + if reinstall { + eprintln!( + "{} Incomplete owned bootstrap found. Retrying now...", + console::style(">>").cyan().bold() + ); + } else { + eprintln!( + "{} No runtime installation found. Bootstrapping now...", + console::style(">>").cyan().bold() + ); } + bootstrap( + prefix, + configured_bundle()?, + configured_offline(), + reinstall, + ) + .await +} - eprintln!( - "{} No runtime installation found. Bootstrapping now...", - console::style(">>").cyan().bold() - ); - bootstrap(prefix, configured_bundle()?, configured_offline()).await +fn prefix_disposition(prefix: &Path) -> miette::Result { + validate_prefix_path(prefix)?; + + if let Some(state) = bootstrap_state::read(prefix).map_err(|error| { + miette::miette!( + "refusing to use install path with invalid bootstrap state: {}\n {error}", + policy::path_for_display(prefix) + ) + })? { + bootstrap_state::validate_identity(&state).map_err(|error| { + miette::miette!( + "refusing to use install path owned by a different runtime: {}\n {error}", + policy::path_for_display(prefix) + ) + })?; + if state.phase() != BootstrapPhase::Installing { + return Err(miette::miette!( + "refusing to use install path with invalid bootstrap state: {}", + policy::path_for_display(prefix) + )); + } + + let metadata_path = crate::config::metadata_path(prefix); + match std::fs::symlink_metadata(&metadata_path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(miette::miette!( + "refusing to recover install path with invalid runtime metadata: {}", + policy::path_for_display(&metadata_path) + )); + } + Ok(_) => { + if let Ok(meta) = read_metadata(prefix) { + crate::config::validate_metadata_identity(&meta).map_err(|error| { + miette::miette!( + "refusing to recover install path owned by a different runtime: {}\n {error}", + policy::path_for_display(prefix) + ) + })?; + if meta.bootstrap_state == BootstrapPhase::Ready + && exec::validate_delegate(prefix, policy::delegate_executable()).is_ok() + { + bootstrap_state::remove(prefix)?; + return Ok(PrefixDisposition::Ready); + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).into_diagnostic(), + } + + return Ok(PrefixDisposition::Bootstrap { reinstall: true }); + } + + if !prefix.exists() || is_empty_dir(prefix)? { + return Ok(PrefixDisposition::Bootstrap { reinstall: false }); + } + + validate_ready_prefix(prefix).map_err(|error| { + miette::miette!( + "refusing to bootstrap into existing non-empty path: {}\n {error}", + policy::path_for_display(prefix) + ) + })?; + Ok(PrefixDisposition::Ready) +} + +fn validate_prefix_path(prefix: &Path) -> miette::Result<()> { + match std::fs::symlink_metadata(prefix) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + Err(miette::miette!( + "refusing to use install path that is not a directory: {}", + policy::path_for_display(prefix) + )) + } + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).into_diagnostic(), + } +} + +fn validate_ready_prefix(prefix: &Path) -> miette::Result<()> { + read_managed_metadata(prefix, "use")?; + exec::validate_delegate(prefix, policy::delegate_executable()) } fn configured_bundle() -> miette::Result> { @@ -92,18 +191,17 @@ fn configured_offline() -> bool { }) } -async fn bootstrap(prefix: &Path, bundle: Option, offline: bool) -> miette::Result<()> { - if prefix.exists() { - if is_bootstrapped(prefix) { - require_managed_prefix(prefix, "use")?; - return Ok(()); - } - if !is_empty_dir(prefix)? { - return Err(miette::miette!( - "refusing to bootstrap into existing non-empty path: {}", - policy::path_for_display(prefix) - )); - } +async fn bootstrap( + prefix: &Path, + bundle: Option, + offline: bool, + reinstall: bool, +) -> miette::Result<()> { + if !reinstall && prefix.exists() && !is_empty_dir(prefix)? { + return Err(miette::miette!( + "refusing to bootstrap into existing non-empty path: {}", + policy::path_for_display(prefix) + )); } let cfg = embedded_config(); @@ -121,40 +219,44 @@ async fn bootstrap(prefix: &Path, bundle: Option, offline: bool) -> mie if lock_content.is_some() { eprintln!(" Using stamped lockfile"); } + let content = lock_content.as_deref().ok_or_else(|| { + if bundle.is_some() { + miette::miette!("configured bundle requires a stamped runtime lock") + } else if crate::config::embedded_bundle().is_some() { + miette::miette!("embedded bundle requires a stamped runtime lock") + } else if offline { + miette::miette!("offline bootstrap requires a stamped runtime lock") + } else { + miette::miette!("runtime has no stamped lockfile; rebuild it with `cs build`") + } + })?; + bootstrap_state::write_installing(prefix)?; + crate::config::invalidate_metadata(prefix)?; if let Some(ref bundle_dir) = bundle { - let content = lock_content - .as_deref() - .ok_or_else(|| miette::miette!("configured bundle requires a stamped runtime lock"))?; eprintln!(" Bundle: {}", policy::path_for_display(bundle_dir)); - install::from_lockfile_with_bundle(prefix, content, bundle_dir, offline).await?; + install::from_lockfile_with_bundle(prefix, content, bundle_dir, offline, reinstall).await?; } else if let Some(embedded_dir) = install::extract_embedded_bundle()? { - let content = lock_content - .as_deref() - .ok_or_else(|| miette::miette!("embedded bundle requires a stamped runtime lock"))?; eprintln!(" Bundle: embedded"); - let result = install::from_lockfile_with_bundle(prefix, content, &embedded_dir, true).await; + let result = + install::from_lockfile_with_bundle(prefix, content, &embedded_dir, true, reinstall) + .await; let _ = std::fs::remove_dir_all(&embedded_dir); result?; } else if offline { - let content = lock_content - .as_deref() - .ok_or_else(|| miette::miette!("offline bootstrap requires a stamped runtime lock"))?; - install::from_lockfile_offline(prefix, content).await?; + install::from_lockfile_offline(prefix, content, reinstall).await?; } else { - let content = lock_content.as_deref().ok_or_else(|| { - miette::miette!("runtime has no stamped lockfile; rebuild it with `cs build`") - })?; - install::from_lockfile(prefix, content).await?; + install::from_lockfile(prefix, content, reinstall).await?; } if let Some(content) = lock_content.as_deref() { constructor_metadata::write_prefix_metadata(prefix, content, &specs)?; } write_configured_policy(prefix, cfg)?; - write_metadata(prefix, &channels, &specs)?; - compile_python_bytecode(prefix); + exec::validate_delegate(prefix, policy::delegate_executable())?; + write_metadata(prefix, &channels, &specs)?; + bootstrap_state::remove(prefix)?; eprintln!( "{} Runtime bootstrapped successfully.", @@ -206,19 +308,178 @@ fn compile_python_bytecode(prefix: &Path) { #[cfg(test)] mod tests { use super::*; + use std::process::Command; + use std::time::{Duration, Instant}; use tempfile::TempDir; #[test] - fn test_is_bootstrapped_true() { + fn test_absent_prefix_needs_initial_bootstrap() { + let tmp = TempDir::new().unwrap(); + let prefix = tmp.path().join("runtime"); + + assert_eq!( + prefix_disposition(&prefix).unwrap(), + PrefixDisposition::Bootstrap { reinstall: false } + ); + } + + #[test] + fn test_unknown_nonempty_prefix_is_refused() { let tmp = TempDir::new().unwrap(); std::fs::create_dir(tmp.path().join("conda-meta")).unwrap(); - assert!(is_bootstrapped(tmp.path())); + + let error = prefix_disposition(tmp.path()).unwrap_err().to_string(); + + assert!(error.contains("existing non-empty path")); } #[test] - fn test_is_bootstrapped_false() { + #[cfg(unix)] + fn test_symlink_prefix_is_refused() { let tmp = TempDir::new().unwrap(); - assert!(!is_bootstrapped(tmp.path())); + let target = tmp.path().join("target"); + let prefix = tmp.path().join("runtime"); + std::fs::create_dir(&target).unwrap(); + std::os::unix::fs::symlink(&target, &prefix).unwrap(); + + let error = prefix_disposition(&prefix).unwrap_err().to_string(); + + assert!(error.contains("not a directory")); + } + + #[test] + fn test_foreign_bootstrap_marker_is_refused() { + let tmp = TempDir::new().unwrap(); + std::fs::write( + bootstrap_state::path(tmp.path()), + serde_json::to_vec_pretty(&serde_json::json!({ + "schema_version": 1, + "state": "installing", + "display_name": policy::display_name(), + "install_name": "another-runtime", + "metadata_file": policy::metadata_file(), + })) + .unwrap(), + ) + .unwrap(); + + let error = prefix_disposition(tmp.path()).unwrap_err().to_string(); + + assert!(error.contains("different runtime")); + assert!(error.contains("another-runtime")); + } + + #[test] + fn test_malformed_bootstrap_marker_is_refused() { + let tmp = TempDir::new().unwrap(); + std::fs::write(bootstrap_state::path(tmp.path()), b"not json").unwrap(); + + let error = prefix_disposition(tmp.path()).unwrap_err().to_string(); + + assert!(error.contains("invalid bootstrap state")); + } + + fn create_ready_prefix(prefix: &Path) { + let delegate = exec::executable_in_prefix(prefix, policy::delegate_executable()); + std::fs::create_dir_all(delegate.parent().unwrap()).unwrap(); + std::fs::write(delegate, b"delegate").unwrap(); + write_metadata(prefix, &[], &[]).unwrap(); + } + + #[test] + fn test_pre_state_marker_metadata_is_accepted_as_ready() { + let tmp = TempDir::new().unwrap(); + create_ready_prefix(tmp.path()); + + assert_eq!( + prefix_disposition(tmp.path()).unwrap(), + PrefixDisposition::Ready + ); + assert!(!bootstrap_state::path(tmp.path()).exists()); + } + + #[test] + fn test_ready_metadata_without_delegate_is_refused() { + let tmp = TempDir::new().unwrap(); + write_metadata(tmp.path(), &[], &[]).unwrap(); + + let error = prefix_disposition(tmp.path()).unwrap_err().to_string(); + + assert!(error.contains("existing non-empty path")); + assert!(error.contains("executable not found")); + } + + #[test] + fn test_owned_incomplete_prefix_forces_reinstall() { + let tmp = TempDir::new().unwrap(); + bootstrap_state::write_installing(tmp.path()).unwrap(); + std::fs::create_dir_all(tmp.path().join("conda-meta")).unwrap(); + + assert_eq!( + prefix_disposition(tmp.path()).unwrap(), + PrefixDisposition::Bootstrap { reinstall: true } + ); + } + + #[test] + fn test_ready_commit_cleans_stale_installing_marker() { + let tmp = TempDir::new().unwrap(); + create_ready_prefix(tmp.path()); + bootstrap_state::write_installing(tmp.path()).unwrap(); + + assert_eq!( + prefix_disposition(tmp.path()).unwrap(), + PrefixDisposition::Ready + ); + assert!(!bootstrap_state::path(tmp.path()).exists()); + } + + #[test] + fn bootstrap_lock_child_reclassifies_after_lock_release() { + let Some(prefix) = std::env::var_os("CONDA_SHIP_LOCK_TEST_PREFIX") else { + return; + }; + let signal = std::env::var_os("CONDA_SHIP_LOCK_TEST_SIGNAL").unwrap(); + std::fs::write(signal, b"waiting").unwrap(); + + let prefix = PathBuf::from(prefix); + let _lock = BootstrapLock::acquire(&prefix).unwrap(); + + assert_eq!( + prefix_disposition(&prefix).unwrap(), + PrefixDisposition::Ready + ); + } + + #[test] + fn test_waiting_process_reclassifies_after_lock_release() { + let tmp = TempDir::new().unwrap(); + let prefix = tmp.path().join("runtime"); + let first = BootstrapLock::acquire(&prefix).unwrap(); + let signal = tmp.path().join("child-waiting"); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "commands::tests::bootstrap_lock_child_reclassifies_after_lock_release", + "--nocapture", + ]) + .env("CONDA_SHIP_LOCK_TEST_PREFIX", &prefix) + .env("CONDA_SHIP_LOCK_TEST_SIGNAL", &signal) + .spawn() + .unwrap(); + + let deadline = Instant::now() + Duration::from_secs(5); + while !signal.exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!(signal.exists(), "child did not reach the lock"); + std::thread::sleep(Duration::from_millis(50)); + assert!(child.try_wait().unwrap().is_none(), "child did not block"); + + create_ready_prefix(&prefix); + drop(first); + + assert!(child.wait().unwrap().success()); } #[test] diff --git a/src/config.rs b/src/config.rs index 79f1156..cf068c0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,10 +1,12 @@ //! Configuration and runtime metadata management. +use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::LazyLock; use miette::{Context, IntoDiagnostic}; +use crate::bootstrap_state::BootstrapPhase; use crate::{policy, runtime_data}; pub use crate::runtime_data::RuntimeConfig; @@ -48,12 +50,48 @@ pub struct PrefixMetadata { pub version: String, pub channels: Vec, pub packages: Vec, + #[serde(default = "ready_bootstrap_phase")] + pub(crate) bootstrap_state: BootstrapPhase, +} + +fn ready_bootstrap_phase() -> BootstrapPhase { + BootstrapPhase::Ready } pub(crate) fn metadata_path(prefix: &Path) -> PathBuf { prefix.join(policy::metadata_file()) } +fn temporary_metadata_path(prefix: &Path) -> PathBuf { + metadata_path(prefix).with_extension("tmp") +} + +fn remove_regular_file_if_present(path: &Path) -> miette::Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err(miette::miette!( + "runtime metadata is not a regular file: {}", + policy::path_for_display(path) + )) + } + Ok(_) => std::fs::remove_file(path) + .into_diagnostic() + .with_context(|| { + format!( + "failed to remove runtime metadata at {}", + policy::path_for_display(path) + ) + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).into_diagnostic(), + } +} + +pub(crate) fn invalidate_metadata(prefix: &Path) -> miette::Result<()> { + remove_regular_file_if_present(&metadata_path(prefix))?; + remove_regular_file_if_present(&temporary_metadata_path(prefix)) +} + pub fn write_metadata( prefix: &Path, channels: &[String], @@ -67,9 +105,41 @@ pub fn write_metadata( version: policy::runtime_version().to_string(), channels: channels.to_vec(), packages: packages.to_vec(), + bootstrap_state: BootstrapPhase::Ready, }; - let json = serde_json::to_string_pretty(&meta).into_diagnostic()?; - std::fs::write(metadata_path(prefix), json).into_diagnostic()?; + let path = metadata_path(prefix); + let temporary_path = temporary_metadata_path(prefix); + remove_regular_file_if_present(&temporary_path)?; + let mut temporary = std::fs::File::create(&temporary_path) + .into_diagnostic() + .with_context(|| { + format!( + "failed to create temporary runtime metadata at {}", + policy::path_for_display(&temporary_path) + ) + })?; + serde_json::to_writer_pretty(&mut temporary, &meta) + .into_diagnostic() + .context("failed to render runtime metadata")?; + temporary + .write_all(b"\n") + .into_diagnostic() + .context("failed to write runtime metadata")?; + temporary + .sync_all() + .into_diagnostic() + .context("failed to sync runtime metadata")?; + drop(temporary); + + remove_regular_file_if_present(&path)?; + std::fs::rename(&temporary_path, &path) + .into_diagnostic() + .with_context(|| { + format!( + "failed to commit runtime metadata at {}", + policy::path_for_display(&path) + ) + })?; Ok(()) } @@ -124,6 +194,16 @@ pub(crate) fn validate_metadata_identity(meta: &PrefixMetadata) -> miette::Resul Ok(()) } +pub(crate) fn validate_metadata_ready(meta: &PrefixMetadata) -> miette::Result<()> { + validate_metadata_identity(meta)?; + if meta.bootstrap_state != BootstrapPhase::Ready { + return Err(miette::miette!( + "runtime metadata is not a ready bootstrap commit" + )); + } + Ok(()) +} + // conda-meta/frozen (CEP 22). /// Write a CEP 22 frozen marker to protect the base prefix from accidental @@ -199,6 +279,25 @@ mod tests { assert_eq!(meta.metadata_file, policy::metadata_file()); assert_eq!(meta.channels, channels); assert_eq!(meta.packages, packages); + assert_eq!(meta.bootstrap_state, BootstrapPhase::Ready); + } + + #[test] + fn test_metadata_without_bootstrap_state_is_a_ready_commit() { + let metadata = serde_json::json!({ + "schema_version": PREFIX_METADATA_SCHEMA_VERSION, + "display_name": policy::display_name(), + "install_name": policy::install_name(), + "metadata_file": policy::metadata_file(), + "version": policy::runtime_version(), + "channels": [], + "packages": [], + }); + + let parsed: PrefixMetadata = serde_json::from_value(metadata).unwrap(); + + assert_eq!(parsed.bootstrap_state, BootstrapPhase::Ready); + validate_metadata_ready(&parsed).unwrap(); } #[test] diff --git a/src/exec.rs b/src/exec.rs index 47a76be..5f7d468 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -48,19 +48,25 @@ fn validate_executable_name(executable: &str) -> miette::Result<()> { Ok(()) } -fn build_delegate_command( - prefix: &Path, - delegate: &str, - args: &[OsString], -) -> miette::Result { +pub(crate) fn validate_delegate(prefix: &Path, delegate: &str) -> miette::Result<()> { validate_executable_name(delegate)?; let delegate_bin = executable_in_prefix(prefix, delegate); - if !delegate_bin.exists() { + if !delegate_bin.is_file() { return Err(miette::miette!( "{delegate} executable not found at {}", policy::path_for_display(&delegate_bin) )); } + Ok(()) +} + +fn build_delegate_command( + prefix: &Path, + delegate: &str, + args: &[OsString], +) -> miette::Result { + validate_delegate(prefix, delegate)?; + let delegate_bin = executable_in_prefix(prefix, delegate); let mut command = Command::new(delegate_bin); command.args(args); apply_delegate_environment(&mut command, prefix)?; diff --git a/src/install.rs b/src/install.rs index 23ff7f2..fbf3282 100644 --- a/src/install.rs +++ b/src/install.rs @@ -2,7 +2,7 @@ use std::{ borrow::Cow, - collections::HashMap, + collections::{HashMap, HashSet}, path::{Path, PathBuf}, sync::Arc, time::{Duration, Instant}, @@ -19,7 +19,7 @@ use rattler::{ package_cache::PackageCache, }; use rattler_conda_types::{ - MatchSpec, ParseMatchSpecOptions, Platform, PrefixRecord, RepoDataRecord, + MatchSpec, PackageName, ParseMatchSpecOptions, Platform, PrefixRecord, RepoDataRecord, }; use rattler_lock::LockFile; use rattler_networking::AuthenticationMiddleware; @@ -76,7 +76,11 @@ fn lockfile_records(lock_content: &str) -> miette::Result<(Platform, Vec miette::Result<()> { +pub async fn from_lockfile( + prefix: &Path, + lock_content: &str, + reinstall: bool, +) -> miette::Result<()> { let (platform, required_packages) = lockfile_records(lock_content)?; let cfg = config::embedded_config(); @@ -91,6 +95,7 @@ pub async fn from_lockfile(prefix: &Path, lock_content: &str) -> miette::Result< &match_specs, client, required_packages, + reinstall, ) .await } @@ -105,6 +110,7 @@ pub async fn from_lockfile_with_bundle( lock_content: &str, bundle_dir: &Path, offline: bool, + reinstall: bool, ) -> miette::Result<()> { let (platform, required_packages) = lockfile_records(lock_content)?; @@ -156,11 +162,13 @@ pub async fn from_lockfile_with_bundle( let cfg = config::embedded_config(); let match_specs = parse_specs(&cfg.packages)?; let installed = PrefixRecord::collect_from_prefix::(prefix).into_diagnostic()?; + let reinstall_packages = reinstall_package_names(&required_packages, reinstall); let mut installer = Installer::new() .with_package_cache(package_cache) .with_target_platform(platform) .with_installed_packages(installed.to_vec()) + .with_reinstall_packages(reinstall_packages) .with_execute_link_scripts(true) .with_requested_specs(match_specs) .with_reporter( @@ -180,10 +188,11 @@ pub async fn from_lockfile_with_bundle( .into_diagnostic() .context("failed to install packages")?; + validate_post_link_script_result(result.post_link_script_result.as_ref())?; + if result.transaction.operations.is_empty() { eprintln!(" {} Already up to date", console::style("✔").green()); } else { - report_post_link_script_failures(result.post_link_script_result.as_ref()); eprintln!( " Installed {} packages in {:.1}s", result.transaction.operations.len(), @@ -194,7 +203,11 @@ pub async fn from_lockfile_with_bundle( } /// Install packages from a lockfile in offline mode (cache only, no bundle). -pub async fn from_lockfile_offline(prefix: &Path, lock_content: &str) -> miette::Result<()> { +pub async fn from_lockfile_offline( + prefix: &Path, + lock_content: &str, + reinstall: bool, +) -> miette::Result<()> { let (platform, required_packages) = lockfile_records(lock_content)?; let cache_dir = default_cache_dir() @@ -204,12 +217,14 @@ pub async fn from_lockfile_offline(prefix: &Path, lock_content: &str) -> miette: let cfg = config::embedded_config(); let match_specs = parse_specs(&cfg.packages)?; let installed = PrefixRecord::collect_from_prefix::(prefix).into_diagnostic()?; + let reinstall_packages = reinstall_package_names(&required_packages, reinstall); let start = Instant::now(); let result = Installer::new() .with_package_cache(package_cache) .with_target_platform(platform) .with_installed_packages(installed.to_vec()) + .with_reinstall_packages(reinstall_packages) .with_execute_link_scripts(true) .with_requested_specs(match_specs) .with_reporter( @@ -222,10 +237,11 @@ pub async fn from_lockfile_offline(prefix: &Path, lock_content: &str) -> miette: .into_diagnostic() .context("failed to install packages (offline mode — are all packages cached?)")?; + validate_post_link_script_result(result.post_link_script_result.as_ref())?; + if result.transaction.operations.is_empty() { eprintln!(" {} Already up to date", console::style("✔").green()); } else { - report_post_link_script_failures(result.post_link_script_result.as_ref()); eprintln!( " Installed {} packages in {:.1}s", result.transaction.operations.len(), @@ -441,22 +457,18 @@ fn verify_bundle_package( Ok(()) } -fn report_post_link_script_failures( +fn validate_post_link_script_result( post_link_result: Option<&Result>, -) { +) -> miette::Result<()> { match post_link_result { - Some(Ok(result)) => { - for line in post_link_failure_lines(result) { - eprintln!("{line}"); - } - } - Some(Err(err)) => { - eprintln!( - " {} failed to inspect post-link script results: {err}", - console::style("!").yellow(), - ); - } - None => {} + Some(Ok(result)) if !result.failed_packages.is_empty() => Err(miette::miette!( + "{}", + post_link_failure_lines(result).join("\n") + )), + Some(Err(error)) => Err(miette::miette!( + "failed to inspect post-link script results: {error}" + )), + Some(Ok(_)) | None => Ok(()), } } @@ -471,8 +483,7 @@ fn post_link_failure_lines(result: &PrePostLinkResult) -> Vec { .map(|package| package.as_normalized().to_string()) .collect(); let mut lines = vec![format!( - " {} post-link scripts failed for {} package(s): {}", - console::style("!").yellow(), + "post-link scripts failed for {} package(s): {}", result.failed_packages.len(), packages.join(", ") )]; @@ -490,13 +501,23 @@ fn post_link_failure_lines(result: &PrePostLinkResult) -> Vec { .map(str::trim) .filter(|line| !line.is_empty()) { - lines.push(format!(" {}: {line}", package.as_normalized())); + lines.push(format!("{}: {line}", package.as_normalized())); } } lines } +fn reinstall_package_names(packages: &[RepoDataRecord], reinstall: bool) -> HashSet { + if !reinstall { + return HashSet::new(); + } + packages + .iter() + .map(|record| record.package_record.name.clone()) + .collect() +} + pub(crate) fn parse_specs(specs: &[String]) -> miette::Result> { specs .iter() @@ -533,12 +554,15 @@ async fn run_installer( specs: &[MatchSpec], client: reqwest_middleware::ClientWithMiddleware, packages: Vec, + reinstall: bool, ) -> miette::Result<()> { let start = Instant::now(); + let reinstall_packages = reinstall_package_names(&packages, reinstall); let result = Installer::new() .with_download_client(client) .with_target_platform(platform) .with_installed_packages(installed.to_vec()) + .with_reinstall_packages(reinstall_packages) .with_execute_link_scripts(true) .with_requested_specs(specs.to_vec()) .with_reporter( @@ -551,10 +575,11 @@ async fn run_installer( .into_diagnostic() .context("failed to install packages")?; + validate_post_link_script_result(result.post_link_script_result.as_ref())?; + if result.transaction.operations.is_empty() { eprintln!(" {} Already up to date", console::style("✔").green()); } else { - report_post_link_script_failures(result.post_link_script_result.as_ref()); eprintln!( " Installed {} packages in {:.1}s", result.transaction.operations.len(), @@ -630,6 +655,26 @@ mod tests { assert!(lines[0].contains("anaconda_powershell_prompt")); assert!(lines[1].contains("anaconda_prompt")); assert!(lines[1].contains("menuinst v2.1.1")); + + let error = validate_post_link_script_result(Some(&Ok(result))) + .unwrap_err() + .to_string(); + assert!(error.contains("post-link scripts failed")); + } + + #[test] + fn test_post_link_result_error_is_fatal() { + let result = Err(LinkScriptError::IoError( + "could not read post-link messages".to_string(), + std::io::Error::other("broken message file"), + )); + + let error = validate_post_link_script_result(Some(&result)) + .unwrap_err() + .to_string(); + + assert!(error.contains("failed to inspect post-link script results")); + assert!(error.contains("could not read post-link messages")); } #[rstest] @@ -711,6 +756,34 @@ mod tests { } } + #[test] + fn test_recovery_reinstalls_an_otherwise_unchanged_package() { + let desired = make_record_with_url("dummy-1.0-0.conda", b"package"); + let current = PrefixRecord::from_repodata_record(desired.clone(), Vec::new()); + + let unchanged = rattler::install::Transaction::from_current_and_desired( + vec![current.clone()], + vec![desired.clone()], + None, + None, + Platform::Linux64, + ) + .unwrap(); + assert_eq!(unchanged.packages_to_install(), 0); + + let reinstall = reinstall_package_names(std::slice::from_ref(&desired), true); + let recovery = rattler::install::Transaction::from_current_and_desired( + vec![current], + vec![desired], + Some(&reinstall), + None, + Platform::Linux64, + ) + .unwrap(); + + assert_eq!(recovery.packages_to_install(), 1); + } + #[rstest] #[case::all_found( vec!["a-1-h1.conda", "b-2-h2.conda"], diff --git a/src/main.rs b/src/main.rs index 739ff65..8778e66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,8 @@ use std::env; use miette::IntoDiagnostic; +mod bootstrap_lock; +mod bootstrap_state; mod commands; mod config; mod constructor_metadata; From 8797608bdf74f0eb13de59cbccdb5264fdb00681 Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Tue, 21 Jul 2026 08:30:20 +0200 Subject: [PATCH 2/2] Clarify bootstrap recovery wording --- .../install-locations-and-ownership.md | 15 ++++++++------- docs/reference/configuration.md | 4 ++-- docs/reference/errors.md | 4 ++-- src/commands.rs | 2 +- src/config.rs | 4 ++-- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/docs/explanation/install-locations-and-ownership.md b/docs/explanation/install-locations-and-ownership.md index 44c3d6f..751e8f2 100644 --- a/docs/explanation/install-locations-and-ownership.md +++ b/docs/explanation/install-locations-and-ownership.md @@ -69,16 +69,17 @@ It records: - package names Later runtime invocations check that metadata before reusing a prefix. -The metadata file is also the bootstrap `ready` commit. Metadata written by -older conda-ship runtimes without an explicit bootstrap state is treated as -ready when its ownership identity and delegate still validate. +The metadata file marks bootstrap complete. Metadata written by older +conda-ship runtimes is accepted when its ownership identity and delegate still +validate. While bootstrap is running, the runtime holds a lock in the prefix's parent directory and writes a separate internal `installing` marker inside the prefix. -That marker is positive ownership evidence for automatic in-place recovery. A -later invocation waits for a live bootstrap to release the lock, then checks -the prefix again. If the previous process stopped, recovery reinstalls every -locked package and reruns post-link scripts without deleting the prefix. +The marker identifies the runtime that started bootstrap. A later invocation +waits for a live bootstrap to release the lock, then checks the prefix again. If +the previous process stopped and the marker matches this runtime, recovery +reinstalls every locked package and reruns post-link scripts without deleting +the prefix. This ownership file is conda-ship-specific. The runtime also writes standard conda prefix metadata: diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 5a4bce8..c530397 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -239,8 +239,8 @@ runtime name as uppercased `RUNTIME_NAME` plus `_PREFIX`. At bootstrap time, the generated runtime writes a separate prefix metadata file inside the managed prefix. That file is used for ownership checks before later -operations touch the prefix. It is written last as the durable bootstrap-ready -commit. An internal installing marker is removed only after that commit. +operations touch the prefix. It is written last to mark bootstrap complete. +The internal installing marker is then removed. The bootstrap also writes standard conda prefix metadata: diff --git a/docs/reference/errors.md b/docs/reference/errors.md index 0014937..f6f4a74 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -102,5 +102,5 @@ conda without depending on terminal formatting. : The prefix does not contain ownership metadata for this runtime. `refusing to use install path with invalid bootstrap state` -: The internal installing marker is malformed or does not belong to this - runtime. conda-ship does not guess ownership of a non-empty prefix. +: The internal installing marker is malformed or belongs to another runtime. + A non-empty prefix without matching ownership state is rejected. diff --git a/src/commands.rs b/src/commands.rs index bb237ae..29027a2 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -64,7 +64,7 @@ pub(crate) async fn ensure_bootstrapped(prefix: &Path) -> miette::Result<()> { if reinstall { eprintln!( - "{} Incomplete owned bootstrap found. Retrying now...", + "{} Previous bootstrap was interrupted. Retrying...", console::style(">>").cyan().bold() ); } else { diff --git a/src/config.rs b/src/config.rs index cf068c0..5973800 100644 --- a/src/config.rs +++ b/src/config.rs @@ -136,7 +136,7 @@ pub fn write_metadata( .into_diagnostic() .with_context(|| { format!( - "failed to commit runtime metadata at {}", + "failed to replace runtime metadata at {}", policy::path_for_display(&path) ) })?; @@ -198,7 +198,7 @@ pub(crate) fn validate_metadata_ready(meta: &PrefixMetadata) -> miette::Result<( validate_metadata_identity(meta)?; if meta.bootstrap_state != BootstrapPhase::Ready { return Err(miette::miette!( - "runtime metadata is not a ready bootstrap commit" + "runtime metadata does not mark bootstrap complete" )); } Ok(())