diff --git a/crates/nexum-runtime/Cargo.toml b/crates/nexum-runtime/Cargo.toml index 0f737bd..d200d07 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/crates/nexum-runtime/Cargo.toml @@ -25,8 +25,8 @@ thiserror.workspace = true async-trait.workspace = true # Newtype boilerplate (`Display`, `AsRef`, `From`) for identity wrappers. derive_more.workspace = true -# `strum::IntoStaticStr` on `LogSource`: the snake_case variant name is -# the tracing `source` field. +# `strum::IntoStaticStr`: the snake_case variant name is the tracing +# `source` field (`LogSource`) and the boot-refusal `error_kind` label. strum.workspace = true tokio.workspace = true # Task lifecycle and graceful shutdown; the sole crate that raw-spawns diff --git a/crates/nexum-runtime/src/addons.rs b/crates/nexum-runtime/src/addons.rs index 165ae01..d977fc7 100644 --- a/crates/nexum-runtime/src/addons.rs +++ b/crates/nexum-runtime/src/addons.rs @@ -3,10 +3,29 @@ //! installs a facility from the resolved config and returns a handle the //! launcher keeps alive for the run. +use metrics_exporter_prometheus::BuildError; use tracing::info; use crate::engine_config::MetricsSection; +/// The foreign cause renders inline, so the operator sees one line. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum PrometheusError { + #[error("invalid [engine.metrics].bind_addr `{addr}`: {cause}")] + BindAddr { + addr: String, + cause: std::net::AddrParseError, + }, + #[error("install Prometheus exporter on {addr}: {cause}")] + Exporter { + addr: std::net::SocketAddr, + cause: BuildError, + }, + #[error("install Prometheus recorder: {cause}")] + Recorder { cause: BuildError }, +} + /// Inputs an add-on reads at install time. pub struct AddOnsContext<'a> { /// Resolved `[engine.metrics]` config. @@ -43,23 +62,25 @@ pub struct PrometheusAddOn; impl RuntimeAddOn for PrometheusAddOn { fn install(&self, ctx: &AddOnsContext<'_>) -> anyhow::Result { if ctx.metrics.enabled { - let addr: std::net::SocketAddr = ctx.metrics.bind_addr.parse().map_err(|e| { - anyhow::anyhow!( - "invalid [engine.metrics].bind_addr `{}`: {e}", - ctx.metrics.bind_addr - ) - })?; + let addr: std::net::SocketAddr = + ctx.metrics + .bind_addr + .parse() + .map_err(|cause| PrometheusError::BindAddr { + addr: ctx.metrics.bind_addr.clone(), + cause, + })?; metrics_exporter_prometheus::PrometheusBuilder::new() .with_http_listener(addr) .install() - .map_err(|e| anyhow::anyhow!("install Prometheus exporter on {addr}: {e}"))?; + .map_err(|cause| PrometheusError::Exporter { addr, cause })?; info!(addr = %addr, "metrics exporter listening at /metrics"); } else { // Recorder installed globally so metrics call sites stay live; // no HTTP port is opened. It accumulates samples in memory, unread. metrics_exporter_prometheus::PrometheusBuilder::new() .install_recorder() - .map_err(|e| anyhow::anyhow!("install Prometheus recorder: {e}"))?; + .map_err(|cause| PrometheusError::Recorder { cause })?; } Ok(AddOnHandle::named("prometheus")) } diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index f42458e..5358fb8 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -31,6 +31,35 @@ use crate::runtime::event_loop; pub use crate::supervisor::WasiClockOverride; use crate::supervisor::{self, Supervisor, Viability}; +/// Launch refusals around the supervisor boot; the wording is operator-pinned. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum LaunchRefusal { + #[error("event loop task terminated abnormally")] + EventLoopGone, + #[error( + "no modules to run - set a module source or declare [[modules]] or \ + [[adapters]] entries in engine.toml" + )] + NothingToRun, + #[error( + "all {modules} module(s) failed initialisation - check the logs above for \ + per-module errors and fix the wasm binary passed as an override" + )] + AllDeadOverride { modules: usize }, + #[error( + "all {modules} module(s) failed initialisation - check the logs above for \ + per-module errors and fix or remove the failing module from engine.toml" + )] + AllDeadConfigured { modules: usize }, + #[error( + "every declared [[subscription]] belongs to an init-failed module - \ + the engine would idle with nothing to run; fix or remove the \ + failing module(s)" + )] + DeadHoldSubs, +} + /// Ambient inputs the launcher reads. pub struct LaunchContext<'a> { /// Owns task spawning and graceful shutdown for the run. @@ -109,7 +138,7 @@ impl RuntimeHandle { fn finish_wait(joined: Option) -> anyhow::Result<()> { match joined { Some(_) => Ok(()), - None => anyhow::bail!("event loop task terminated abnormally"), + None => Err(LaunchRefusal::EventLoopGone.into()), } } @@ -208,10 +237,7 @@ impl AssembledRuntime { ) .await? } else { - anyhow::bail!( - "no modules to run - set a module source or declare [[modules]] or \ - [[adapters]] entries in engine.toml" - ); + return Err(LaunchRefusal::NothingToRun.into()); }; let alive = supervisor.alive_count(); @@ -224,19 +250,12 @@ impl AssembledRuntime { "supervisor ready" ); if alive == 0 { - if wasm_override { - anyhow::bail!( - "all {} module(s) failed initialisation - check the logs above for \ - per-module errors and fix the wasm binary passed as an override", - supervisor.module_count(), - ); + let modules = supervisor.module_count(); + return Err(if wasm_override { + LaunchRefusal::AllDeadOverride { modules }.into() } else { - anyhow::bail!( - "all {} module(s) failed initialisation - check the logs above for \ - per-module errors and fix or remove the failing module from engine.toml", - supervisor.module_count(), - ); - } + LaunchRefusal::AllDeadConfigured { modules }.into() + }); } // The OS signal listener: SIGINT/SIGTERM ends it, and its end (or @@ -282,11 +301,7 @@ impl AssembledRuntime { } match plan.viability(extension_streams.len()) { - Viability::DeadHoldSubs => anyhow::bail!( - "every declared [[subscription]] belongs to an init-failed module - \ - the engine would idle with nothing to run; fix or remove the \ - failing module(s)" - ), + Viability::DeadHoldSubs => return Err(LaunchRefusal::DeadHoldSubs.into()), Viability::Nothing => { // Nothing to drive: return a handle whose event loop is // already complete so `wait` resolves immediately. diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index 1eb9c98..14c31c8 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -227,6 +227,13 @@ pub fn downcast_service(service: &Arc) -> Optio erased.downcast().ok() } +/// Two wired extensions claim one service namespace. +#[derive(Debug, thiserror::Error)] +#[error("duplicate extension service namespace {namespace}")] +pub struct DuplicateServiceNamespace { + pub namespace: &'static str, +} + /// Immutable per-namespace service map, built once at boot. #[derive(Clone, Default)] pub struct HostServices(Arc>>); @@ -242,7 +249,7 @@ impl HostServices { /// duplicate. pub fn from_extensions( extensions: &[Arc>], - ) -> anyhow::Result { + ) -> Result { let mut map = BTreeMap::new(); for ext in extensions { let Some(service) = ext.service() else { @@ -250,7 +257,7 @@ impl HostServices { }; let namespace = ext.namespace(); if map.insert(namespace, service).is_some() { - anyhow::bail!("duplicate extension service namespace {namespace}"); + return Err(DuplicateServiceNamespace { namespace }); } } Ok(Self(Arc::new(map))) @@ -272,10 +279,10 @@ impl HostServices { self, namespace: &'static str, service: Arc, - ) -> anyhow::Result { + ) -> Result { let mut map = Arc::unwrap_or_clone(self.0); if map.insert(namespace, service).is_some() { - anyhow::bail!("duplicate extension service namespace {namespace}"); + return Err(DuplicateServiceNamespace { namespace }); } Ok(Self(Arc::new(map))) } diff --git a/crates/nexum-runtime/src/manifest/error.rs b/crates/nexum-runtime/src/manifest/error.rs index 1856729..61b15fb 100644 --- a/crates/nexum-runtime/src/manifest/error.rs +++ b/crates/nexum-runtime/src/manifest/error.rs @@ -1,5 +1,6 @@ //! Error types for manifest parsing and capability enforcement. +use strum::IntoStaticStr; use thiserror::Error; /// Errors from loading or validating a manifest. @@ -56,7 +57,8 @@ pub struct CapabilityViolation { } /// A component's WIT imports exceed its declared capabilities. -#[derive(Debug, Error)] +#[derive(Debug, Error, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] #[non_exhaustive] pub enum CapabilityError { /// A gated import was not declared in `[capabilities]`. diff --git a/crates/nexum-runtime/src/manifest/mod.rs b/crates/nexum-runtime/src/manifest/mod.rs index 1f63c65..16d9751 100644 --- a/crates/nexum-runtime/src/manifest/mod.rs +++ b/crates/nexum-runtime/src/manifest/mod.rs @@ -12,10 +12,11 @@ mod types; pub(crate) use capabilities::enforce_capabilities; pub use capabilities::{CapabilityRegistry, NamespaceCaps}; +pub(crate) use error::{CapabilityError, ParseError}; pub(crate) use load::{host_allowed, load}; pub use types::ExtensionSections; pub(crate) use types::{ComponentKind, LoadedManifest, ResourceSection, Subscription}; -// CapabilityViolation, ParseError, and the *Section structs are +// CapabilityViolation and the *Section structs are // reachable through these functions' return / argument types; // consumers that need to name them directly do so via // `crate::manifest::error::*` or `::types::*`. diff --git a/crates/nexum-runtime/src/supervisor/admission.rs b/crates/nexum-runtime/src/supervisor/admission.rs index 2797255..6ff9c89 100644 --- a/crates/nexum-runtime/src/supervisor/admission.rs +++ b/crates/nexum-runtime/src/supervisor/admission.rs @@ -3,8 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; -use anyhow::{Result, anyhow}; - +use super::load::LoadRefusal; use crate::host::component::RuntimeTypes; use crate::host::extension::{Extension, HostService, HostServices, ProviderKind}; use crate::manifest::{self, CapabilityRegistry}; @@ -20,19 +19,20 @@ pub(super) type ProviderKinds = BTreeMap<&'static str, ProviderRow>; pub(super) fn provider_kinds( extensions: &[Arc>], services: &HostServices, -) -> Result> { +) -> Result, LoadRefusal> { let mut kinds = ProviderKinds::new(); for ext in extensions { let Some(provider) = ext.provider() else { continue; }; - let service = services.raw(ext.namespace()).cloned().ok_or_else(|| { - anyhow!( - "extension {} registers provider kind {} without a host service", - ext.namespace(), - provider.kind(), - ) - })?; + let service = + services + .raw(ext.namespace()) + .cloned() + .ok_or_else(|| LoadRefusal::ServicelessKind { + namespace: ext.namespace(), + kind: provider.kind(), + })?; register_kind(&mut kinds, provider, service)?; } Ok(kinds) @@ -43,16 +43,16 @@ fn register_kind( kinds: &mut ProviderKinds, provider: Box>, service: Arc, -) -> Result<()> { +) -> Result<(), LoadRefusal> { let kind = provider.kind(); if kinds.insert(kind, (provider, service)).is_some() { - return Err(anyhow!("provider kind {kind} is registered twice")); + return Err(LoadRefusal::KindRegisteredTwice { kind }); } Ok(()) } -pub(super) fn registered_kinds(kinds: &ProviderKinds) -> String { - kinds.keys().copied().collect::>().join(", ") +pub(super) fn registered_kinds(kinds: &ProviderKinds) -> Vec<&'static str> { + kinds.keys().copied().collect() } pub(super) fn extension_subscription_vocabulary( @@ -69,15 +69,16 @@ pub(super) fn enforce_extension_sections( owner: &str, sections: &manifest::ExtensionSections, extensions: &[Arc>], -) -> Result<()> { +) -> Result<(), LoadRefusal> { for key in sections.keys() { let claimed = extensions .iter() .any(|ext| ext.manifest_sections().contains(&key.as_str())); if !claimed { - return Err(anyhow!( - "{owner} declares manifest section [{key}]; no wired extension claims it" - )); + return Err(LoadRefusal::SectionUnclaimed { + owner: owner.to_owned(), + section: key.clone(), + }); } } Ok(()) @@ -87,23 +88,23 @@ pub(super) fn enforce_extension_sections( /// subscription kind, or manifest section. pub(super) fn enforce_extension_uniqueness( extensions: &[Arc>], -) -> Result<()> { +) -> Result<(), LoadRefusal> { let mut namespaces = BTreeSet::new(); let mut kinds = BTreeSet::new(); let mut sections = BTreeSet::new(); for ext in extensions { let namespace = ext.namespace(); if !namespaces.insert(namespace) { - return Err(anyhow!("extension namespace {namespace} is claimed twice")); + return Err(LoadRefusal::ExtensionNamespaceClaimed { namespace }); } - for kind in ext.subscriptions() { - if !kinds.insert(*kind) { - return Err(anyhow!("subscription kind {kind} is claimed twice")); + for &kind in ext.subscriptions() { + if !kinds.insert(kind) { + return Err(LoadRefusal::SubscriptionKindClaimed { kind }); } } - for section in ext.manifest_sections() { - if !sections.insert(*section) { - return Err(anyhow!("manifest section [{section}] is claimed twice")); + for §ion in ext.manifest_sections() { + if !sections.insert(section) { + return Err(LoadRefusal::SectionClaimed { section }); } } } diff --git a/crates/nexum-runtime/src/supervisor/artifact.rs b/crates/nexum-runtime/src/supervisor/artifact.rs index 0cedff3..4bf7c25 100644 --- a/crates/nexum-runtime/src/supervisor/artifact.rs +++ b/crates/nexum-runtime/src/supervisor/artifact.rs @@ -5,11 +5,12 @@ use std::path::Path; -use anyhow::{Context, Error, Result, bail}; +use anyhow::{Context, Error, Result}; use tracing::{debug, warn}; use wasmtime::component::Component; use wasmtime::{CodeBuilder, Engine}; +use super::load::LoadRefusal; use crate::digest::{ContentDigest, DigestMismatch}; /// The only production compile path; the verified bytes are the compiled bytes. @@ -23,6 +24,7 @@ pub(super) fn read_verified_component( std::fs::read(path).with_context(|| format!("read component {}", path.display()))?; let actual = ContentDigest::of_bytes(&bytes); match declared { + // A mismatch stays its own anyhow root: callers downcast to `DigestMismatch`. Some(declared) => { if actual != *declared { return Err(DigestMismatch { @@ -34,11 +36,12 @@ pub(super) fn read_verified_component( } debug!(component = %path.display(), digest = %actual, "component digest verified"); } - None if require_digest => bail!( - "no [module].component digest for {} and [engine] require_component_digest is set; \ - pin the artifact's sha256 in its module.toml", - path.display(), - ), + None if require_digest => { + return Err(LoadRefusal::DigestUnpinned { + path: path.to_owned(), + } + .into()); + } None => warn!( component = %path.display(), digest = %actual, diff --git a/crates/nexum-runtime/src/supervisor/load.rs b/crates/nexum-runtime/src/supervisor/load.rs index f91eaa5..849f016 100644 --- a/crates/nexum-runtime/src/supervisor/load.rs +++ b/crates/nexum-runtime/src/supervisor/load.rs @@ -1,9 +1,11 @@ //! Load one module or provider: admission, verified compile, instantiation, `init`. -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; -use anyhow::{Context, Error, Result, anyhow}; +use anyhow::{Context, Error, Result}; +use strum::IntoStaticStr; +use thiserror::Error as ThisError; use tracing::{info, warn}; use wasmtime::component::{Component, Linker}; @@ -33,6 +35,57 @@ use crate::manifest::{self, CapabilityRegistry, ComponentKind, LoadedManifest, S use crate::module_id::ModuleId; use crate::runtime::dispatch_rate::TokenBucket; +/// Admission refusals ahead of instantiation; the wording is operator-pinned. +#[derive(Debug, ThisError, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +pub(super) enum LoadRefusal { + #[error("{owner} declares manifest section [{section}]; no wired extension claims it")] + SectionUnclaimed { owner: String, section: String }, + #[error("extension namespace {namespace} is claimed twice")] + ExtensionNamespaceClaimed { namespace: &'static str }, + #[error("subscription kind {kind} is claimed twice")] + SubscriptionKindClaimed { kind: &'static str }, + #[error("manifest section [{section}] is claimed twice")] + SectionClaimed { section: &'static str }, + #[error("provider kind {kind} is registered twice")] + KindRegisteredTwice { kind: &'static str }, + #[error("extension {namespace} registers provider kind {kind} without a host service")] + ServicelessKind { + namespace: &'static str, + kind: &'static str, + }, + #[error( + "{} declares the worker kind; an [[adapters]] entry requires a \ + module.toml declaring a registered provider kind ({})", + path.display(), + registered.join(", ") + )] + WorkerKindAdapter { + path: PathBuf, + registered: Vec<&'static str>, + }, + #[error( + "{} declares unregistered provider kind {kind}; registered kinds: {}", + path.display(), + registered.join(", ") + )] + UnregisteredKind { + path: PathBuf, + kind: String, + registered: Vec<&'static str>, + }, + #[error( + "module {module} subscribes to unknown event kind {kind}; no wired extension declares it" + )] + UnknownEventKind { module: ModuleId, kind: String }, + #[error( + "no [module].component digest for {} and [engine] require_component_digest is set; \ + pin the artifact's sha256 in its module.toml", + path.display() + )] + DigestUnpinned { path: PathBuf }, +} + /// Restarts reuse the cache, so the boot-time digest holds for every run. pub(super) struct CachedArtifact { /// `Component` is internally `Arc`-backed, so the cache is cheap. @@ -293,10 +346,11 @@ pub(super) async fn module( "cron subscriptions are declared but inert in 0.2 (lands in 0.3)", ), Subscription::Extension { kind, .. } if !extension_kinds.contains(kind.as_str()) => { - return Err(anyhow!( - "module {module_namespace} subscribes to unknown event kind {kind}; \ - no wired extension declares it" - )); + return Err(LoadRefusal::UnknownEventKind { + module: module_namespace.clone(), + kind: kind.clone(), + } + .into()); } _ => {} } @@ -344,23 +398,20 @@ pub(super) async fn provider( // An unregistered kind refuses before compile. let row: &ProviderRow = match &loaded_manifest.manifest.module.kind { ComponentKind::Worker => { - return Err(anyhow!( - "{} declares the worker kind; an [[adapters]] entry requires a \ - module.toml declaring a registered provider kind ({})", - entry.path.display(), - super::admission::registered_kinds(&shared.kinds), - )); - } - ComponentKind::Provider(spelling) => { - shared.kinds.get(spelling.as_str()).ok_or_else(|| { - anyhow!( - "{} declares unregistered provider kind {spelling}; registered \ - kinds: {}", - entry.path.display(), - super::admission::registered_kinds(&shared.kinds), - ) - })? + return Err(LoadRefusal::WorkerKindAdapter { + path: entry.path.clone(), + registered: super::admission::registered_kinds(&shared.kinds), + } + .into()); } + ComponentKind::Provider(spelling) => shared + .kinds + .get(spelling.as_str()) + .ok_or_else(|| LoadRefusal::UnregisteredKind { + path: entry.path.clone(), + kind: spelling.clone(), + registered: super::admission::registered_kinds(&shared.kinds), + })?, }; info!( component = %entry.path.display(), diff --git a/crates/nexum-runtime/src/supervisor/mod.rs b/crates/nexum-runtime/src/supervisor/mod.rs index 7e4e991..b2f11bf 100644 --- a/crates/nexum-runtime/src/supervisor/mod.rs +++ b/crates/nexum-runtime/src/supervisor/mod.rs @@ -23,15 +23,17 @@ use tracing::info; use wasmtime::Engine; use wasmtime::component::Linker; +use crate::digest::DigestMismatch; use crate::engine_config::{EngineConfig, ModuleEntry, ModuleLimits}; use crate::host::component::{Components, RuntimeTypes}; use crate::host::extension::{Extension, HostServices, ProviderManifest}; use crate::host::state::HostState; +use crate::manifest::CapabilityError; use crate::runtime::poison_policy::PoisonPolicy; use admission::{ProviderKinds, capability_registry, enforce_extension_uniqueness, provider_kinds}; use cursors::ChainLogCursors; -use load::{LoadedModule, LoadedProvider}; -use prepass::{enforce_subscriptions, load_required_manifest, manifest_namespace}; +use load::{LoadRefusal, LoadedModule, LoadedProvider}; +use prepass::{BootRefusal, enforce_subscriptions, load_required_manifest, manifest_namespace}; use role::Role; /// Owns every loaded module and provider and exposes the dispatch surface. @@ -84,54 +86,58 @@ impl Supervisor { extensions: &[Arc>], clocks: Option, ) -> Result { - let shared = wire_extensions(engine, components, extensions, clocks, true)?; - let registry = capability_registry(&shared.extensions); - let prepass = prepass::run(engine_cfg, ®istry)?; - // Providers boot first, so every module store built after already - // routes to the installed instances. - let providers = load_role( - &engine_cfg.adapters, - prepass.adapter_manifests, - Role::Adapter, - |e| &e.path, - async |entry, manifest| { - load::provider( - &shared, - entry, - manifest, - &engine_cfg.limits, - engine_cfg.engine.require_component_digest, - ) - .await - }, - ) - .await?; - let provider_manifests = project_manifests(&providers); - let modules = load_role( - &engine_cfg.modules, - prepass.module_manifests, - Role::Module, - |e| &e.path, - async |entry, manifest| { - load::module( - &shared, - linker, - entry, - manifest, - &engine_cfg.limits, - engine_cfg.engine.require_component_digest, - &provider_manifests, - ) - .await - }, - ) - .await?; - Ok(assemble( - shared, - modules, - providers, - engine_cfg.limits.poison(), - )) + let booted: Result = async { + let shared = wire_extensions(engine, components, extensions, clocks, true)?; + let registry = capability_registry(&shared.extensions); + let prepass = prepass::run(engine_cfg, ®istry)?; + // Providers boot first, so every module store built after already + // routes to the installed instances. + let providers = load_role( + &engine_cfg.adapters, + prepass.adapter_manifests, + Role::Adapter, + |e| &e.path, + async |entry, manifest| { + load::provider( + &shared, + entry, + manifest, + &engine_cfg.limits, + engine_cfg.engine.require_component_digest, + ) + .await + }, + ) + .await?; + let provider_manifests = project_manifests(&providers); + let modules = load_role( + &engine_cfg.modules, + prepass.module_manifests, + Role::Module, + |e| &e.path, + async |entry, manifest| { + load::module( + &shared, + linker, + entry, + manifest, + &engine_cfg.limits, + engine_cfg.engine.require_component_digest, + &provider_manifests, + ) + .await + }, + ) + .await?; + Ok(assemble( + shared, + modules, + providers, + engine_cfg.limits.poison(), + )) + } + .await; + booted.inspect_err(count_boot_refusal) } /// Single-component boot for `just run` without an `engine.toml`. @@ -144,38 +150,42 @@ impl Supervisor { extensions: &[Arc>], clocks: Option, ) -> Result { - // Provider kinds come only from `engine.toml`, so none register here. - let shared = wire_extensions(engine, components, extensions, clocks, false)?; - let registry = capability_registry(&shared.extensions); - let loaded_manifest = load_required_manifest( - &entry.path, - entry.manifest.as_deref(), - ®istry, - Role::Module.manifest_role(), - )?; - enforce_subscriptions( - Role::Module, - &manifest_namespace(&loaded_manifest), - &loaded_manifest, - &env.configured_chains, - )?; - let loaded = load::module( - &shared, - linker, - entry, - loaded_manifest, - env.limits, - env.require_component_digest, - &[], - ) - .await?; - Ok(Self { - shared, - modules: vec![loaded], - providers: Vec::new(), - policy: env.limits.poison(), - chain_log_cursors: ChainLogCursors::default(), - }) + let booted: Result = async { + // Provider kinds come only from `engine.toml`, so none register here. + let shared = wire_extensions(engine, components, extensions, clocks, false)?; + let registry = capability_registry(&shared.extensions); + let loaded_manifest = load_required_manifest( + &entry.path, + entry.manifest.as_deref(), + ®istry, + Role::Module.manifest_role(), + )?; + enforce_subscriptions( + Role::Module, + &manifest_namespace(&loaded_manifest), + &loaded_manifest, + &env.configured_chains, + )?; + let loaded = load::module( + &shared, + linker, + entry, + loaded_manifest, + env.limits, + env.require_component_digest, + &[], + ) + .await?; + Ok(Self { + shared, + modules: vec![loaded], + providers: Vec::new(), + policy: env.limits.poison(), + chain_log_cursors: ChainLogCursors::default(), + }) + } + .await; + booted.inspect_err(count_boot_refusal) } pub fn module_count(&self) -> usize { @@ -207,6 +217,32 @@ impl Supervisor { } } +/// Counts a refusal under its typed kind; wraps without a typed root go uncounted. +fn count_boot_refusal(err: &anyhow::Error) { + let Some(kind) = boot_refusal_kind(err) else { + return; + }; + metrics::counter!("nexum_runtime_boot_refusals_total", "error_kind" => kind).increment(1); +} + +/// A root missing here is a refusal class the counter never sees. +fn boot_refusal_kind(err: &anyhow::Error) -> Option<&'static str> { + err.chain().find_map(|cause| { + if let Some(refusal) = cause.downcast_ref::() { + return Some(refusal.into()); + } + if let Some(refusal) = cause.downcast_ref::() { + return Some(refusal.into()); + } + if let Some(violation) = cause.downcast_ref::() { + return Some(violation.into()); + } + cause + .downcast_ref::() + .map(|_| "digest_mismatch") + }) +} + /// The resulting [`Shared`] is the one wiring every later phase reads. /// `with_provider_kinds: false` skips [`provider_kinds`], which refuses a serviceless kind. fn wire_extensions( diff --git a/crates/nexum-runtime/src/supervisor/prepass.rs b/crates/nexum-runtime/src/supervisor/prepass.rs index ba27c9a..c0101b1 100644 --- a/crates/nexum-runtime/src/supervisor/prepass.rs +++ b/crates/nexum-runtime/src/supervisor/prepass.rs @@ -5,12 +5,83 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use alloy_chains::Chain; -use anyhow::{Context, Error, Result, anyhow}; +use anyhow::{Context, Result}; +use strum::IntoStaticStr; +use thiserror::Error; use tracing::{info, warn}; use super::role::Role; use crate::engine_config::EngineConfig; -use crate::manifest::{self, CapabilityRegistry, LoadedManifest, Subscription}; +use crate::manifest::{self, CapabilityRegistry, LoadedManifest, ParseError, Subscription}; + +/// Refusals before any compile; the wording is operator-pinned. +#[derive(Debug, Error, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +pub(super) enum BootRefusal { + #[error( + "name {name} is claimed twice: {held_role} {} and {role} {}; \ + [module].name must be unique across [[modules]] and [[adapters]]", + held.display(), + path.display() + )] + NamespaceClaimed { + name: String, + held_role: &'static str, + held: PathBuf, + role: &'static str, + path: PathBuf, + }, + #[error(transparent)] + Manifest(#[from] ParseError), + #[error( + "manifest {} not found for component {}", + manifest.display(), + component.display() + )] + ManifestNotFound { + manifest: PathBuf, + component: PathBuf, + }, + #[error( + "no module.toml for component {}; ship one next to the component \ + or pass its path explicitly (an empty `required = []` under \ + [capabilities] grants nothing)", + component.display() + )] + ManifestMissing { component: PathBuf }, + #[error( + "{noun} {name} subscribes to chain {chain_id} but no engine.toml was found \ + (running on defaults, no chains configured); create engine.toml with a \ + [chains.{chain_id}] entry" + )] + UnconfiguredChainDefaulted { + noun: &'static str, + name: String, + chain_id: u64, + }, + #[error( + "{noun} {name} subscribes to chain {chain_id} but engine.toml declares no \ + [chains.{chain_id}] entry; configured chains: {}", + fmt_chain_ids(configured) + )] + UnconfiguredChain { + noun: &'static str, + name: String, + chain_id: u64, + configured: BTreeSet, + }, +} + +/// An empty set reads as `none`, never as an empty list. +fn fmt_chain_ids(ids: &BTreeSet) -> String { + if ids.is_empty() { + return "none".to_owned(); + } + ids.iter() + .map(u64::to_string) + .collect::>() + .join(", ") +} /// One ledger spans both roles: they derive the same keccak local-store namespace. pub(super) type NamespaceLedger = BTreeMap; @@ -21,14 +92,15 @@ pub(super) fn claim_namespace( name: &str, role: &'static str, path: &Path, -) -> Result<()> { +) -> Result<(), BootRefusal> { if let Some((held_role, held_path)) = ledger.get(name) { - return Err(anyhow!( - "name {name} is claimed twice: {held_role} {} and {role} {}; \ - [module].name must be unique across [[modules]] and [[adapters]]", - held_path.display(), - path.display(), - )); + return Err(BootRefusal::NamespaceClaimed { + name: name.to_owned(), + held_role, + held: held_path.clone(), + role, + path: path.to_path_buf(), + }); } ledger.insert(name.to_owned(), (role, path.to_path_buf())); Ok(()) @@ -45,24 +117,20 @@ pub(super) fn load_required_manifest( explicit: Option<&Path>, registry: &CapabilityRegistry, role: &'static str, -) -> Result { +) -> Result { match resolve_manifest_path(component, explicit).as_deref() { Some(p) if p.exists() => { info!(manifest = %p.display(), role, "loading component manifest"); Ok(manifest::load(p, registry)?) } // Explicit paths only: sibling discovery requires `.exists()`. - Some(p) => Err(anyhow!( - "manifest {} not found for component {}", - p.display(), - component.display(), - )), - None => Err(anyhow!( - "no module.toml for component {}; ship one next to the component \ - or pass its path explicitly (an empty `required = []` under \ - [capabilities] grants nothing)", - component.display(), - )), + Some(p) => Err(BootRefusal::ManifestNotFound { + manifest: p.to_path_buf(), + component: component.to_path_buf(), + }), + None => Err(BootRefusal::ManifestMissing { + component: component.to_path_buf(), + }), } } @@ -119,7 +187,7 @@ pub(super) fn enforce_subscriptions( name: &str, loaded: &LoadedManifest, chains: &ConfiguredChains, -) -> Result<()> { +) -> Result<(), BootRefusal> { for sub in &loaded.manifest.subscriptions { let (Subscription::Block { chain_id } | Subscription::ChainLog { chain_id, .. }) = sub else { @@ -137,29 +205,21 @@ pub(super) fn unconfigured_chain( name: &str, chain_id: u64, chains: &ConfiguredChains, -) -> Error { +) -> BootRefusal { let noun = role.claim_role(); if chains.defaulted { - return anyhow!( - "{noun} {name} subscribes to chain {chain_id} but no engine.toml was found \ - (running on defaults, no chains configured); create engine.toml with a \ - [chains.{chain_id}] entry" - ); + return BootRefusal::UnconfiguredChainDefaulted { + noun, + name: name.to_owned(), + chain_id, + }; + } + BootRefusal::UnconfiguredChain { + noun, + name: name.to_owned(), + chain_id, + configured: chains.ids.clone(), } - let configured = if chains.ids.is_empty() { - "none".to_owned() - } else { - chains - .ids - .iter() - .map(u64::to_string) - .collect::>() - .join(", ") - }; - anyhow!( - "{noun} {name} subscribes to chain {chain_id} but engine.toml declares no \ - [chains.{chain_id}] entry; configured chains: {configured}" - ) } /// Every manifest loaded, every name claimed, every subscribed chain gated, diff --git a/crates/nexum-runtime/src/supervisor/tests/boot_refusals.rs b/crates/nexum-runtime/src/supervisor/tests/boot_refusals.rs index fec2e2f..0cf4b16 100644 --- a/crates/nexum-runtime/src/supervisor/tests/boot_refusals.rs +++ b/crates/nexum-runtime/src/supervisor/tests/boot_refusals.rs @@ -2,6 +2,52 @@ use super::*; +/// A root missing here is a refusal class no operator dashboard sees, so the +/// table is asserted through the same `with_context` wrap boot applies. +#[test] +fn every_typed_refusal_root_labels_the_counter_under_a_context_wrap() { + let digest = ContentDigest::of_bytes(b"artifact"); + let cases: Vec<(anyhow::Error, &str)> = vec![ + ( + BootRefusal::ManifestMissing { + component: PathBuf::from("orphan.wasm"), + } + .into(), + "manifest_missing", + ), + ( + LoadRefusal::SectionClaimed { section: "venue" }.into(), + "section_claimed", + ), + ( + CapabilityError::UnknownWasi { + wit_import: "wasi:sockets/tcp@0.2.0".to_owned(), + } + .into(), + "unknown_wasi", + ), + ( + DigestMismatch { + path: PathBuf::from("pinned.wasm"), + declared: digest, + actual: digest, + } + .into(), + "digest_mismatch", + ), + ]; + for (err, kind) in cases { + let wrapped = err.context("module pinned.wasm"); + assert_eq!(boot_refusal_kind(&wrapped), Some(kind), "{wrapped:#}"); + } +} + +/// An untyped refusal is counted under no kind rather than a wrong one. +#[test] +fn an_untyped_refusal_carries_no_counter_label() { + assert_eq!(boot_refusal_kind(&anyhow::anyhow!("engine gone")), None); +} + /// Rejected before instantiation, naming the registered kinds; a manifest /// without a kind defaults to an event-module. #[tokio::test] @@ -17,11 +63,18 @@ async fn boot_rejects_provider_whose_manifest_is_an_event_module() { /// The refusal names the registered kinds. #[tokio::test] async fn boot_rejects_an_unregistered_provider_kind() { - BootScenario::over(mock_components()) + let refusal = BootScenario::over(mock_components()) .extensions(acme_extensions()) .adapter(TestManifest::new("bad").kind("gadget")) .expect_refusal() - .await + .await; + assert!(matches!( + refusal.root::(), + Some(LoadRefusal::UnregisteredKind { kind, registered, .. }) + if kind == "gadget" && registered == &["acme-adapter"] + )); + // Operator wording pin. + refusal .names("unregistered provider kind gadget") .names("acme-adapter"); } @@ -50,11 +103,19 @@ async fn boot_admits_a_registered_provider_kind_past_the_kind_gate() { /// the boot before any entry loads. #[tokio::test] async fn boot_refuses_a_provider_kind_without_a_host_service() { - BootScenario::over(mock_components()) + let refusal = BootScenario::over(mock_components()) .extensions(serviceless_acme_extensions()) .expect_refusal() - .await - .names("extension acme registers provider kind acme-adapter without a host service"); + .await; + assert!(matches!( + refusal.root::(), + Some(LoadRefusal::ServicelessKind { + namespace: "acme", + kind: "acme-adapter" + }) + )); + // Operator wording pin. + refusal.names("extension acme registers provider kind acme-adapter without a host service"); } /// Provider kinds come only from `engine.toml`, so single boot skips the diff --git a/crates/nexum-runtime/src/test_utils/scenario.rs b/crates/nexum-runtime/src/test_utils/scenario.rs index d3f3363..7053a38 100644 --- a/crates/nexum-runtime/src/test_utils/scenario.rs +++ b/crates/nexum-runtime/src/test_utils/scenario.rs @@ -320,6 +320,11 @@ impl From for Refusal { } impl Refusal { + /// The typed root under the context wraps, for `matches!` on a variant. + pub fn root(&self) -> Option<&E> { + self.0.chain().find_map(|cause| cause.downcast_ref::()) + } + /// Assert the refusal names `needle` somewhere in its context chain. #[track_caller] pub fn names(self, needle: &str) -> Self {