diff --git a/crates/nexum-runtime/src/host/actor.rs b/crates/nexum-runtime/src/host/actor.rs new file mode 100644 index 00000000..10d5c461 --- /dev/null +++ b/crates/nexum-runtime/src/host/actor.rs @@ -0,0 +1,58 @@ +//! The supervised host-actor primitive: one component instance the host +//! holds and others call. The store is refuelled before each guest call, +//! a trap is projected onto a typed fault instead of unwinding into the +//! caller, and each instance sits behind an [`ActorSlot`] async mutex held +//! across the guest await, so one store never runs two guest calls at once. + +use std::sync::Arc; + +use tokio::sync::Mutex as AsyncMutex; +use wasmtime::Store; + +use super::component::RuntimeTypes; +use super::state::HostState; + +/// One supervised actor behind its serialising mutex. A wasmtime `Store` +/// is not `Sync`; concurrent callers queue here. +pub type ActorSlot = Arc>; + +/// A guest call failed outside the component's typed error space. +#[derive(Debug, thiserror::Error)] +pub enum ActorFault { + /// The pre-call refuel failed; the guest was never entered. + #[error("refuel failed: {0}")] + Refuel(wasmtime::Error), + /// The guest trapped. Carries the root cause only; the wasm frame + /// list stays out of the caller-facing message. + #[error("trapped: {}", .0.root_cause())] + Trap(wasmtime::Error), +} + +/// A supervised component store: refuelled before each guest call so every +/// invocation starts from a full budget, with traps projected onto +/// [`ActorFault`]. +pub struct SupervisedStore { + store: Store>, + fuel_per_call: u64, +} + +impl SupervisedStore { + /// Supervise an instantiated store with a per-call fuel budget. + pub fn new(store: Store>, fuel_per_call: u64) -> Self { + Self { + store, + fuel_per_call, + } + } + + /// Refuel, then run one guest call against the store. + pub async fn call( + &mut self, + call: impl AsyncFnOnce(&mut Store>) -> wasmtime::Result, + ) -> Result { + self.store + .set_fuel(self.fuel_per_call) + .map_err(ActorFault::Refuel)?; + call(&mut self.store).await.map_err(ActorFault::Trap) + } +} diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index 6f57e6a8..ed14de95 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -60,13 +60,46 @@ pub trait ProviderKind: Send + Sync + 'static { /// Adds the provider's imports to a provider linker. fn link(&self, linker: &mut Linker>) -> anyhow::Result<()>; - /// Install one instantiated provider behind the extension's service. + /// Instantiate one provider and install it behind the owning service. + /// [`Installed::Dead`] reports a failed guest `init`; an `Err` is a + /// boot error. async fn install( &self, - component: &Component, - store: Store>, + instance: ProviderInstance<'_, T>, service: &Arc, - ) -> anyhow::Result<()>; + ) -> anyhow::Result; +} + +/// One provider instance ready to install: the compiled component, the +/// linker the kind's [`ProviderKind::link`] populated, the supervised +/// store, the manifest `[config]`, and the per-call fuel budget. +pub struct ProviderInstance<'a, T: RuntimeTypes> { + /// Compiled provider component. + pub component: &'a Component, + /// Linker carrying the kind's imports plus the WASI base. + pub linker: &'a Linker>, + /// Store the instance runs in; the kind takes ownership. + pub store: Store>, + /// Manifest `[config]` handed to the guest `init`. + pub config: Vec<(String, String)>, + /// Fuel budget applied before each routed guest call. + pub fuel_per_call: u64, +} + +/// Outcome of one provider install. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Installed { + /// `init` succeeded; the instance is installed and routable. + Live, + /// `init` returned a fault; the instance is loaded but not routable. + Dead, +} + +/// Downcast a type-erased service to `S`. `None` when the type differs. +pub fn downcast_service(service: &Arc) -> Option> { + let service = Arc::clone(service); + let erased: Arc = service; + erased.downcast().ok() } /// Immutable per-namespace service map: each extension's [`HostService`] @@ -103,9 +136,7 @@ impl HostServices { /// The service under `namespace`, downcast to its concrete type. /// `None` when the namespace is absent or the type does not match. pub fn get(&self, namespace: &str) -> Option> { - let service = Arc::clone(self.0.get(namespace)?); - let erased: Arc = service; - erased.downcast().ok() + downcast_service(self.0.get(namespace)?) } /// The raw type-erased service under `namespace`. diff --git a/crates/nexum-runtime/src/host/mod.rs b/crates/nexum-runtime/src/host/mod.rs index 1cf10f5d..ac9e0634 100644 --- a/crates/nexum-runtime/src/host/mod.rs +++ b/crates/nexum-runtime/src/host/mod.rs @@ -19,11 +19,14 @@ //! namespace) an extension is wired in through at the composition root. //! Domain extensions such as cow-api live in their own crates and plug //! in through this seam rather than being hard-linked into the core host. +//! - [`actor`]: the supervised host-actor primitive provider instances +//! run behind (refuel, trap projection, serialising slot). //! - [`http`]: the wasi:http outgoing gate enforcing the per-module //! `[capabilities.http].allow` list. //! - [`logs`]: the typed module-log pipeline (capture points -> router -> //! tracing event + retention store) and its embedder read surface. +pub mod actor; pub mod component; pub mod error; pub mod extension; diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/nexum-runtime/src/host/venue_registry.rs index f13d87aa..8e93942d 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -9,9 +9,9 @@ //! Status and cancel are pass-throughs; they are not submissions, so they //! skip the header, the guard, and the quota. //! -//! Invocation is serialised per adapter. A wasmtime `Store` is not `Sync`, -//! so each adapter sits behind its own async mutex: concurrent client calls -//! to the same venue queue on that mutex, while calls to different venues run +//! Invocation is serialised per adapter through the supervised-actor +//! primitive: each adapter sits behind its own [`ActorSlot`], so concurrent +//! client calls to the same venue queue while calls to different venues run //! in parallel. The lock is held across the guest await, which is the whole //! point - it is the actor boundary that keeps one adapter store //! single-threaded. @@ -28,17 +28,24 @@ use std::fmt; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +use anyhow::{Context, anyhow}; +use async_trait::async_trait; use futures::future::BoxFuture; use nexum_status_body::StatusBody; use tokio::sync::Mutex as AsyncMutex; -use tracing::warn; +use tracing::{info, warn}; use wasmtime::Store; +use wasmtime::component::HasSelf; use crate::bindings::{ IntentHeader, IntentStatus, IntentStatusUpdate, Quotation, RateLimit, SubmitOutcome, - VenueAdapter, VenueError, + VenueAdapter, VenueError, nexum, }; +use crate::host::actor::{ActorFault, ActorSlot, SupervisedStore}; use crate::host::component::RuntimeTypes; +use crate::host::extension::{ + HostService, Installed, ProviderInstance, ProviderKind, downcast_service, +}; use crate::host::state::HostState; /// Default per-caller submission budget within [`DEFAULT_QUOTA_WINDOW`]. @@ -207,41 +214,31 @@ pub trait VenueInvoker: Send { fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>>; } -/// The live adapter: a supervised wasmtime `Store` plus the `venue-adapter` -/// bindings, refuelled before each guest call. A trap is projected onto -/// `unavailable` rather than propagated: a misbehaving adapter must not be -/// the caller's fault, and it must not unwind through the registry into the -/// calling module's store. +/// The live adapter: a [`SupervisedStore`] plus the `venue-adapter` +/// bindings. Each guest call is refuelled by the primitive; a trap is +/// projected onto `unavailable` rather than propagated, because a +/// misbehaving adapter must not be the caller's fault and must not unwind +/// through the registry into the calling module's store. pub struct VenueActor { - store: Store>, + actor: SupervisedStore, bindings: VenueAdapter, - fuel_per_call: u64, } impl VenueActor { /// Wrap an instantiated adapter store for routing. pub fn new(store: Store>, bindings: VenueAdapter, fuel_per_call: u64) -> Self { Self { - store, + actor: SupervisedStore::new(store, fuel_per_call), bindings, - fuel_per_call, } } - - /// Refuel the store before a guest call so each invocation starts from a - /// full budget, mirroring the supervisor's per-event refuel. - fn refuel(&mut self) -> Result<(), VenueError> { - self.store - .set_fuel(self.fuel_per_call) - .map_err(|e| VenueError::Unavailable(format!("adapter refuel failed: {e}"))) - } } -/// Project a wasmtime trap into the venue-error space. The root cause is -/// carried so an operator sees why the adapter died without the wasm frame -/// list leaking to the calling module. -fn trap_to_venue_error(trap: wasmtime::Error) -> VenueError { - VenueError::Unavailable(format!("adapter trapped: {}", trap.root_cause())) +/// Project an actor fault into the venue-error space. The fault carries +/// the root cause only, so an operator sees why the adapter died without +/// the wasm frame list leaking to the calling module. +fn venue_fault(fault: ActorFault) -> VenueError { + VenueError::Unavailable(format!("adapter {fault}")) } impl VenueInvoker for VenueActor { @@ -250,31 +247,21 @@ impl VenueInvoker for VenueActor { body: &'a [u8], ) -> BoxFuture<'a, Result> { Box::pin(async move { - self.refuel()?; - match self - .bindings - .videre_venue_adapter() - .call_derive_header(&mut self.store, body) + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_derive_header(store, body).await) .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } + .map_err(venue_fault)? }) } fn quote<'a>(&'a mut self, body: &'a [u8]) -> BoxFuture<'a, Result> { Box::pin(async move { - self.refuel()?; - match self - .bindings - .videre_venue_adapter() - .call_quote(&mut self.store, body) + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_quote(store, body).await) .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } + .map_err(venue_fault)? }) } @@ -283,52 +270,37 @@ impl VenueInvoker for VenueActor { body: &'a [u8], ) -> BoxFuture<'a, Result> { Box::pin(async move { - self.refuel()?; - match self - .bindings - .videre_venue_adapter() - .call_submit(&mut self.store, body) + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_submit(store, body).await) .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } + .map_err(venue_fault)? }) } fn status(&mut self, receipt: Vec) -> BoxFuture<'_, Result> { Box::pin(async move { - self.refuel()?; - match self - .bindings - .videre_venue_adapter() - .call_status(&mut self.store, &receipt) + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_status(store, &receipt).await) .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } + .map_err(venue_fault)? }) } fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { Box::pin(async move { - self.refuel()?; - match self - .bindings - .videre_venue_adapter() - .call_cancel(&mut self.store, &receipt) + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_cancel(store, &receipt).await) .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } + .map_err(venue_fault)? }) } } -/// One installed adapter behind its serialising mutex. -type AdapterSlot = Arc>; +/// One installed adapter behind its serialising slot. +type AdapterSlot = ActorSlot; /// Per-caller charge history, pruned to the quota window on each touch. #[derive(Default)] @@ -380,9 +352,11 @@ fn status_body(status: IntentStatus) -> StatusBody { /// The shared registry state. Cloning a [`VenueRegistry`] is an `Arc` bump; /// every module store carries the same handle, so a submission from any -/// module reaches the same adapters and the same quota ledger. +/// module reaches the same adapters and the same quota ledger. Adapters +/// install through the shared handle at provider boot, before any client +/// call routes. struct VenueRegistryInner { - adapters: HashMap, + adapters: Mutex>, guard: Arc, quota: SubmitQuota, ledger: Mutex, @@ -400,6 +374,9 @@ pub struct VenueRegistry { inner: Arc, } +/// The registry is the venue-routing host service. +impl HostService for VenueRegistry {} + impl VenueRegistry { /// An empty registry: no adapters, the unit guard, the default quota. /// This is what an adapter store (which cannot call the client face) and @@ -408,10 +385,28 @@ impl VenueRegistry { VenueRegistryBuilder::new(SubmitQuota::default()).build() } + /// Install an adapter under its venue id. Rejects a duplicate id: two + /// adapters answering the same venue would silently shadow one another, + /// which is a config error worth failing boot over. + pub fn install( + &self, + venue: VenueId, + invoker: impl VenueInvoker + 'static, + ) -> Result<(), DuplicateVenue> { + let mut adapters = self.inner.adapters.lock().expect("adapter map poisoned"); + if adapters.contains_key(&venue) { + return Err(DuplicateVenue { venue }); + } + adapters.insert(venue, Arc::new(AsyncMutex::new(invoker))); + Ok(()) + } + /// Resolve a venue id to its installed adapter slot. fn resolve(&self, venue: &VenueId) -> Result { self.inner .adapters + .lock() + .expect("adapter map poisoned") .get(venue) .cloned() .ok_or(VenueError::UnknownVenue) @@ -683,7 +678,85 @@ impl VenueRegistry { /// Number of installed, routable adapters. pub fn venue_count(&self) -> usize { - self.inner.adapters.len() + self.inner + .adapters + .lock() + .expect("adapter map poisoned") + .len() + } +} + +/// The venue-adapter provider kind: boots a `videre:venue/venue-adapter` +/// component and installs its actor in the venue registry. Registered by +/// the boot path while the registry lives in-core; the videre extension +/// takes it over. +pub struct VenueAdapterKind; + +impl VenueAdapterKind { + /// The manifest kind spelling. + pub const KIND: &'static str = "venue-adapter"; +} + +#[async_trait] +impl ProviderKind for VenueAdapterKind { + fn kind(&self) -> &'static str { + Self::KIND + } + + fn link(&self, linker: &mut wasmtime::component::Linker>) -> anyhow::Result<()> { + // The scoped transport only; the WASI base is the host's, and the + // withheld core interfaces fail instantiation. + nexum::host::chain::add_to_linker::, HasSelf>>(linker, |s| s)?; + nexum::host::messaging::add_to_linker::, HasSelf>>( + linker, + |s| s, + )?; + Ok(()) + } + + async fn install( + &self, + instance: ProviderInstance<'_, T>, + service: &Arc, + ) -> anyhow::Result { + let registry = downcast_service::(service) + .ok_or_else(|| anyhow!("the venue-adapter kind requires the venue-registry service"))?; + let ProviderInstance { + component, + linker, + mut store, + config, + fuel_per_call, + } = instance; + let bindings = VenueAdapter::instantiate_async(&mut store, component, linker) + .await + .map_err(anyhow::Error::from) + .context("instantiate adapter")?; + // The venue id is the adapter's namespace: its manifest name. + let venue_id = VenueId::from(&*store.data().run.module); + match bindings + .call_init(&mut store, &config) + .await + .map_err(anyhow::Error::from)? + { + Ok(()) => info!(adapter = %venue_id, "adapter init succeeded"), + Err(e) => { + warn!( + adapter = %venue_id, + kind = crate::host::error::fault_label(&e), + message = crate::host::error::fault_message(&e), + "adapter init failed - loaded but marked dead", + ); + return Ok(Installed::Dead); + } + } + registry + .install( + venue_id.clone(), + VenueActor::new(store, bindings, fuel_per_call), + ) + .with_context(|| format!("install adapter {venue_id}"))?; + Ok(Installed::Live) } } @@ -713,12 +786,11 @@ fn prune(history: &mut VecDeque, window: Duration) { } } -/// Assembles a [`VenueRegistry`]: adapters install first (at supervisor -/// boot, before any module store carries the built registry), then the -/// registry freezes. The guard defaults to the unit guard; the egress-guard +/// Assembles a [`VenueRegistry`]'s policy: guard, quota, and watch bounds +/// freeze at build; adapters install afterwards through the shared handle +/// at provider boot. The guard defaults to the unit guard; the egress-guard /// epic overrides it here. pub struct VenueRegistryBuilder { - adapters: HashMap, guard: Arc, quota: SubmitQuota, watch_limit: WatchLimit, @@ -729,7 +801,6 @@ impl VenueRegistryBuilder { /// the default watch limit. pub fn new(quota: SubmitQuota) -> Self { Self { - adapters: HashMap::new(), guard: Arc::new(()), quota, watch_limit: WatchLimit::default(), @@ -750,22 +821,6 @@ impl VenueRegistryBuilder { self } - /// Install an adapter under its venue id. Rejects a duplicate id: two - /// adapters answering the same venue would silently shadow one another, - /// which is a config error worth failing boot over. - pub fn install( - &mut self, - venue: VenueId, - invoker: impl VenueInvoker + 'static, - ) -> Result<(), DuplicateVenue> { - if self.adapters.contains_key(&venue) { - return Err(DuplicateVenue { venue }); - } - self.adapters - .insert(venue, Arc::new(AsyncMutex::new(invoker))); - Ok(()) - } - /// Freeze the builder into a shared registry. pub fn build(self) -> VenueRegistry { if self.quota.max_charges == 0 { @@ -784,7 +839,7 @@ impl VenueRegistryBuilder { WatchLimit::new(self.watch_limit.max_entries.max(1), self.watch_limit.expiry); VenueRegistry { inner: Arc::new(VenueRegistryInner { - adapters: self.adapters, + adapters: Mutex::new(HashMap::new()), guard: self.guard, quota, watch_limit, @@ -1009,8 +1064,9 @@ mod tests { if let Some(guard) = guard { builder = builder.with_guard(guard); } - builder.install(cow(), adapter).expect("install adapter"); - builder.build() + let registry = builder.build(); + registry.install(cow(), adapter).expect("install adapter"); + registry } #[tokio::test] @@ -1310,13 +1366,13 @@ mod tests { #[test] fn duplicate_venue_id_is_rejected() { - let mut builder = VenueRegistryBuilder::new(SubmitQuota::default()); + let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); let a = Arc::new(StubCalls::default()); let b = Arc::new(StubCalls::default()); - builder + registry .install(cow(), StubAdapter::new(a)) .expect("first install"); - let err = builder + let err = registry .install(cow(), StubAdapter::new(b)) .expect_err("second install collides"); assert_eq!(err.venue, cow()); @@ -1467,10 +1523,11 @@ mod tests { /// A registry with the given watch bounds and one echo-receipt-capable /// stub adapter under `cow`. fn watch_bounded_registry(watch_limit: WatchLimit, adapter: StubAdapter) -> VenueRegistry { - let mut builder = - VenueRegistryBuilder::new(SubmitQuota::default()).with_watch_limit(watch_limit); - builder.install(cow(), adapter).expect("install adapter"); - builder.build() + let registry = VenueRegistryBuilder::new(SubmitQuota::default()) + .with_watch_limit(watch_limit) + .build(); + registry.install(cow(), adapter).expect("install adapter"); + registry } #[tokio::test] diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 4264c6e6..8ad2b4d3 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -53,20 +53,20 @@ pub const VENUE_NAMESPACE: NamespaceCaps = NamespaceCaps { ifaces: VENUE_CAPABILITIES, }; -/// The interfaces a `venue-adapter` world links: the scoped transport -/// only. An adapter has no local-store, remote-store, identity, or -/// logging - it moves bytes to and from its venue and nothing else. `http` -/// is not listed here for the same reason it is not in the core set: it +/// The interfaces a provider world links: the scoped transport only. A +/// provider has no local-store, remote-store, identity, or logging - it +/// moves bytes to and from its counterparty and nothing else. `http` is +/// not listed here for the same reason it is not in the core set: it /// gates `wasi:http/*` and is handled by the registry directly. -pub const ADAPTER_CAPABILITIES: &[&str] = &["chain", "messaging"]; +pub const PROVIDER_CAPABILITIES: &[&str] = &["chain", "messaging"]; -/// The adapter namespace: the same `nexum:host/` prefix as core but only -/// the scoped-transport interfaces. Validating an adapter manifest against +/// The provider namespace: the same `nexum:host/` prefix as core but only +/// the scoped-transport interfaces. Validating a provider manifest against /// a registry built from this namespace rejects a declaration of any core -/// interface an adapter must not reach (e.g. `local-store`) as unknown. -pub const ADAPTER_NAMESPACE: NamespaceCaps = NamespaceCaps { +/// interface a provider must not reach (e.g. `local-store`) as unknown. +pub const PROVIDER_NAMESPACE: NamespaceCaps = NamespaceCaps { prefix: "nexum:host/", - ifaces: ADAPTER_CAPABILITIES, + ifaces: PROVIDER_CAPABILITIES, }; /// Import prefix of the wasi:http package. Every interface under it @@ -135,14 +135,14 @@ impl CapabilityRegistry { } } - /// The registry a venue adapter validates against: only the scoped - /// transport interfaces plus `http`. An adapter manifest that declares + /// The registry a provider validates against: only the scoped + /// transport interfaces plus `http`. A provider manifest that declares /// a core-only capability (e.g. `local-store`) fails as unknown here, - /// and the adapter linker withholds the same interfaces so the + /// and the provider linker withholds the same interfaces so the /// component cannot instantiate against them either. - pub fn adapter() -> Self { + pub fn provider() -> Self { Self { - namespaces: vec![ADAPTER_NAMESPACE], + namespaces: vec![PROVIDER_NAMESPACE], } } @@ -446,11 +446,11 @@ mod tests { } #[test] - fn adapter_registry_knows_only_scoped_transport() { + fn provider_registry_knows_only_scoped_transport() { // The scoped transport plus http are known; the core-only - // interfaces an adapter must not reach are not, so a manifest + // interfaces a provider must not reach are not, so a manifest // declaring them fails validation as unknown. - let r = CapabilityRegistry::adapter(); + let r = CapabilityRegistry::provider(); assert!(r.is_known("chain")); assert!(r.is_known("messaging")); assert!(r.is_known("http")); @@ -461,8 +461,8 @@ mod tests { } #[test] - fn adapter_registry_maps_transport_imports_but_not_core_only() { - let r = CapabilityRegistry::adapter(); + fn provider_registry_maps_transport_imports_but_not_core_only() { + let r = CapabilityRegistry::provider(); assert_eq!(r.wit_import_to_cap("nexum:host/chain@0.1.0"), Some("chain")); assert_eq!( r.wit_import_to_cap("nexum:host/messaging@0.1.0"), @@ -472,15 +472,15 @@ mod tests { r.wit_import_to_cap("wasi:http/outgoing-handler@0.2.12"), Some("http") ); - // A core-only interface is not a recognised adapter capability. + // A core-only interface is not a recognised provider capability. assert_eq!(r.wit_import_to_cap("nexum:host/local-store@0.1.0"), None); } #[test] - fn adapter_manifest_declaring_a_core_only_cap_is_unknown() { + fn provider_manifest_declaring_a_core_only_cap_is_unknown() { // The load path validates declared names against the registry; an - // adapter declaring `local-store` must surface as unknown. - let r = CapabilityRegistry::adapter(); + // provider declaring `local-store` must surface as unknown. + let r = CapabilityRegistry::provider(); assert!(!r.is_known("local-store")); assert!(r.known_names().split(", ").all(|n| n != "local-store")); } diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index 8abb74ea..6ad22674 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -295,8 +295,8 @@ enabled = true } #[test] - fn module_kind_defaults_to_event_module() { - use crate::manifest::types::ModuleKind; + fn component_kind_defaults_to_the_worker() { + use crate::manifest::types::ComponentKind; let manifest: Manifest = toml::from_str( r#" [module] @@ -304,12 +304,12 @@ name = "plain" "#, ) .expect("parse"); - assert_eq!(manifest.module.kind, ModuleKind::EventModule); + assert_eq!(manifest.module.kind, ComponentKind::Worker); } #[test] - fn module_kind_parses_venue_adapter() { - use crate::manifest::types::ModuleKind; + fn component_kind_carries_a_provider_spelling() { + use crate::manifest::types::ComponentKind; let manifest: Manifest = toml::from_str( r#" [module] @@ -318,23 +318,28 @@ kind = "venue-adapter" "#, ) .expect("parse"); - assert_eq!(manifest.module.kind, ModuleKind::VenueAdapter); + assert_eq!( + manifest.module.kind, + ComponentKind::Provider("venue-adapter".to_owned()), + ); } + /// An unknown spelling parses as a provider kind; boot refuses it + /// against the registered kinds, where the valid set is known. #[test] - fn module_kind_rejects_unknown_variant() { - let err = toml::from_str::( + fn component_kind_keeps_an_unregistered_spelling_for_boot_to_refuse() { + use crate::manifest::types::ComponentKind; + let manifest: Manifest = toml::from_str( r#" [module] name = "bad" kind = "gadget" "#, ) - .expect_err("unknown kind rejected"); - let msg = err.to_string(); - assert!( - msg.contains("venue-adapter") || msg.contains("event-module"), - "error names the valid kinds: {msg}", + .expect("parse"); + assert_eq!( + manifest.module.kind, + ComponentKind::Provider("gadget".to_owned()), ); } diff --git a/crates/nexum-runtime/src/manifest/mod.rs b/crates/nexum-runtime/src/manifest/mod.rs index fef64e1e..e93e179b 100644 --- a/crates/nexum-runtime/src/manifest/mod.rs +++ b/crates/nexum-runtime/src/manifest/mod.rs @@ -35,7 +35,7 @@ mod types; pub(crate) use capabilities::enforce_capabilities; pub use capabilities::{CapabilityRegistry, NamespaceCaps}; pub(crate) use load::{fallback_manifest, host_allowed, load}; -pub(crate) use types::{LoadedManifest, ModuleKind, ResourceSection, Subscription}; +pub(crate) use types::{ComponentKind, LoadedManifest, ResourceSection, Subscription}; // CapabilityViolation, ParseError, and the *Section structs are // reachable through these functions' return / argument types; // consumers that need to name them directly do so via diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index 78c62fb4..dbaf774a 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -4,6 +4,8 @@ //! and validation logic lives in [`mod@super::load`]; capability enforcement //! in [`super::capabilities`]. +use std::fmt; + use serde::Deserialize; /// Core capability names: the `nexum:host` interfaces the `event-module` @@ -115,31 +117,54 @@ pub struct ModuleSection { pub version: String, #[serde(default)] pub component: String, - /// Which component kind this manifest describes. Defaults to - /// `event-module` so every existing `module.toml` keeps its meaning; - /// a venue adapter sets `kind = "venue-adapter"`. The supervisor picks - /// the bindgen and the scoped capability set from this discriminator. + /// Which component kind this manifest describes. Defaults to the + /// worker kind (`event-module`) so every existing `module.toml` keeps + /// its meaning; a provider names its registered kind. The supervisor + /// resolves the boot path from this discriminator. #[serde(default)] - pub kind: ModuleKind, + pub kind: ComponentKind, /// Per-module resource overrides; each unset field inherits the engine /// `[limits]` default. #[serde(default)] pub resources: ResourceSection, } -/// The component kind a manifest declares. The runtime carries two: the -/// original event-module over the six core primitives, and the venue -/// adapter over scoped transport only. Defaulting to `event-module` -/// preserves the meaning of every manifest written before adapters -/// existed. -#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum ModuleKind { - /// Event-driven automation over the six core primitives. +/// The worker kind's manifest spelling. +pub const WORKER_KIND: &str = "event-module"; + +/// The component kind a manifest declares: the core worker kind, or the +/// manifest spelling of a provider kind an extension registers. Defaults +/// to the worker so every manifest written before providers existed keeps +/// its meaning; an unregistered provider spelling is refused at boot, +/// where the registered kinds are known. +#[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)] +#[serde(from = "String")] +pub enum ComponentKind { + /// Event-driven worker over the six core primitives (`event-module`). #[default] - EventModule, - /// A single-venue adapter over scoped chain, messaging, and HTTP. - VenueAdapter, + Worker, + /// A provider the host holds behind a serialised actor, named by its + /// manifest spelling. + Provider(String), +} + +impl From for ComponentKind { + fn from(kind: String) -> Self { + if kind == WORKER_KIND { + Self::Worker + } else { + Self::Provider(kind) + } + } +} + +impl fmt::Display for ComponentKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Worker => f.write_str(WORKER_KIND), + Self::Provider(kind) => f.write_str(kind), + } + } } /// `[module.resources]` overrides layered over the engine `[limits]` /// defaults. Every field is optional; an unset field keeps the default. diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index f31a9a52..154e8193 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -26,6 +26,7 @@ //! tasks own one per-chain backoff timer each, so a //! chain-A connection drop does not block chain-B events. +use std::collections::BTreeMap; use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -38,12 +39,14 @@ use wasmtime::component::{Component, HasSelf, Linker, ResourceTable}; use wasmtime::{Engine, Store}; use wasmtime_wasi::{HostMonotonicClock, HostWallClock, WasiCtxBuilder}; -use crate::bindings::{Config, EventModule, VenueAdapter, nexum}; +use crate::bindings::{Config, EventModule, nexum}; use crate::engine_config::{ AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, OutboundHttpLimits, }; use crate::host::component::{Components, RuntimeTypes, StateHandle, StateStore}; -use crate::host::extension::{Extension, HostServices}; +use crate::host::extension::{ + Extension, HostService, HostServices, Installed, ProviderInstance, ProviderKind, +}; use crate::host::http::HttpGate; #[cfg(test)] use crate::host::local_store_redb::LocalStore; @@ -51,9 +54,9 @@ use crate::host::logs::{LogRecord, LogSource, RunId, StdioStream}; #[cfg(test)] use crate::host::provider_pool::ProviderPool; use crate::host::state::HostState; -use crate::host::venue_registry::{VenueActor, VenueId, VenueRegistry, VenueRegistryBuilder}; +use crate::host::venue_registry::{VenueAdapterKind, VenueRegistry, VenueRegistryBuilder}; use crate::manifest::{ - self, CapabilityRegistry, LoadedManifest, ModuleKind, ResourceSection, Subscription, + self, CapabilityRegistry, ComponentKind, LoadedManifest, ResourceSection, Subscription, }; /// Owns every loaded module and exposes the dispatch surface the @@ -256,19 +259,52 @@ struct LoadedModule { dispatch_bucket: crate::runtime::dispatch_rate::TokenBucket, } -/// A venue adapter instantiated into a supervised store, ready to install in -/// the venue registry. It boots through the same store, fuel, and memory -/// machinery as a module but carries no subscriptions: modules reach it -/// through the registry, not through dispatch. Adapter restart and poison -/// handling are still a later change; an `init` failure leaves `alive` false -/// so the adapter is loaded but not routable. -struct LoadedAdapter { - /// Venue id the adapter answers for (its manifest name). - venue_id: VenueId, - /// The refuelable adapter store, ready to serialise behind a registry mutex. - actor: VenueActor, - /// Whether `init` succeeded; a failed adapter is not installed for routing. - alive: bool, +/// One registered provider kind paired with the service its installs bind to. +type ProviderRow = (Box>, Arc); + +/// Registered provider kinds, keyed by their manifest spelling. +type ProviderKinds = BTreeMap<&'static str, ProviderRow>; + +/// Collect each extension's provider kind paired with that extension's +/// service. Refuses a duplicate spelling and a provider whose extension +/// owns no service to install into. +fn provider_kinds( + extensions: &[Arc>], + services: &HostServices, +) -> Result> { + 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(), + ) + })?; + register_kind(&mut kinds, provider, service)?; + } + Ok(kinds) +} + +/// Insert one kind row, refusing a duplicate manifest spelling. +fn register_kind( + kinds: &mut ProviderKinds, + provider: Box>, + service: Arc, +) -> Result<()> { + let kind = provider.kind(); + if kinds.insert(kind, (provider, service)).is_some() { + return Err(anyhow!("provider kind {kind} is registered twice")); + } + Ok(()) +} + +/// Comma-joined registered provider kind spellings, for boot errors. +fn registered_kinds(kinds: &ProviderKinds) -> String { + kinds.keys().copied().collect::>().join(", ") } impl Supervisor { @@ -285,44 +321,44 @@ impl Supervisor { ) -> Result { let registry = capability_registry(extensions); let services = HostServices::from_extensions(extensions)?; - // Adapters instantiate first: the venue registry must contain them - // before any module store (which carries the built registry) is - // built. Adapters link only their scoped transport, against a - // dedicated linker built from the same core backends, and their own - // stores carry an empty registry since an adapter cannot call the + let venue_registry = VenueRegistryBuilder::new(engine_cfg.limits.quota()) + .with_watch_limit(engine_cfg.limits.watch()) + .build(); + // Provider kinds the boot loop resolves manifest kinds against: + // every extension-registered kind plus the venue-adapter row, seeded + // here while the registry lives in-core; the videre extension takes + // it over. + let mut kinds = provider_kinds(extensions, &services)?; + register_kind( + &mut kinds, + Box::new(VenueAdapterKind), + Arc::new(venue_registry.clone()), + )?; + // Providers boot first into the shared registry handle, so every + // module store built below already routes to the installed venues. + // Providers link only their kind's scoped imports, and their own + // stores carry an empty registry since a provider cannot call the // client face. - let adapter_linker = build_adapter_linker::(engine)?; - let adapter_registry = CapabilityRegistry::adapter(); - let mut registry_builder = VenueRegistryBuilder::new(engine_cfg.limits.quota()) - .with_watch_limit(engine_cfg.limits.watch()); + let provider_registry = CapabilityRegistry::provider(); let adapters_total = engine_cfg.adapters.len(); let mut adapters_alive = 0; for entry in &engine_cfg.adapters { - let loaded = Self::load_adapter( + let installed = Self::load_provider( engine, - &adapter_linker, entry, components, &engine_cfg.limits, - &adapter_registry, + &provider_registry, clocks.as_ref(), services.clone(), + &kinds, ) .await - .with_context(|| format!("load adapter {}", entry.path.display()))?; - if loaded.alive { + .with_context(|| format!("load provider {}", entry.path.display()))?; + if installed == Installed::Live { adapters_alive += 1; - registry_builder - .install(loaded.venue_id.clone(), loaded.actor) - .with_context(|| format!("install adapter {}", loaded.venue_id))?; - } else { - warn!( - adapter = %loaded.venue_id, - "adapter init failed - not installed for routing", - ); } } - let venue_registry = registry_builder.build(); let mut modules = Vec::with_capacity(engine_cfg.modules.len()); for entry in &engine_cfg.modules { @@ -672,64 +708,78 @@ impl Supervisor { }) } - /// Load one `[[adapters]]` entry: resolve its manifest, verify it - /// declares the venue-adapter kind, enforce the scoped-transport - /// capability set, build a supervised store carrying the operator's - /// HTTP and messaging grants, instantiate the `VenueAdapter` bindings - /// against the adapter linker, and run `init`. Nothing dispatches to - /// the result yet; it boots so the registry can later reach it. + /// Load one `[[adapters]]` entry: resolve its manifest, resolve the + /// declared kind against the registered provider kinds, enforce the + /// scoped-transport capability set, build a supervised store carrying + /// the operator's HTTP and messaging grants, and hand the instance to + /// its kind to instantiate and install. [`Installed::Dead`] marks a + /// failed guest `init`: loaded and counted, but not routable. // One flat argument per shared input threaded onto the store, matching // the module load path. #[allow(clippy::too_many_arguments)] - async fn load_adapter( + async fn load_provider( engine: &Engine, - linker: &Linker>, entry: &AdapterEntry, components: &Components, limits_cfg: &ModuleLimits, registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, services: HostServices, - ) -> Result> { + kinds: &ProviderKinds, + ) -> Result { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { Some(p) if p.exists() => { - info!(manifest = %p.display(), "loading adapter manifest"); + info!(manifest = %p.display(), "loading provider manifest"); manifest::load(p, registry)? } _ => { warn!( component = %entry.path.display(), - "no module.toml - falling back to anonymous adapter" + "no module.toml - falling back to anonymous provider" ); manifest::fallback_manifest() } }; // The manifest kind is the discriminator: an [[adapters]] entry - // whose manifest is (or defaults to) an event-module is a config - // error, caught here before instantiation. A fallback manifest has - // the default event-module kind, so an adapter must ship a - // module.toml that declares the venue-adapter kind explicitly. - let kind = loaded_manifest.manifest.module.kind; - if kind != ModuleKind::VenueAdapter { - return Err(anyhow!( - "adapter {} declares module kind {kind:?}; an [[adapters]] entry requires \ - a module.toml with [module] kind = \"venue-adapter\"", - entry.path.display(), - )); - } + // must name a registered provider kind, caught here before + // instantiation. A fallback manifest has the default worker kind, + // so a provider must ship a module.toml that declares its kind + // explicitly. + let (kind, service) = 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(), + registered_kinds(kinds), + )); + } + ComponentKind::Provider(spelling) => kinds.get(spelling.as_str()).ok_or_else(|| { + anyhow!( + "{} declares unregistered provider kind {spelling}; registered \ + kinds: {}", + entry.path.display(), + registered_kinds(kinds), + ) + })?, + }; - info!(component = %entry.path.display(), "compiling adapter component"); + info!( + component = %entry.path.display(), + kind = kind.kind(), + "compiling provider component", + ); let component = Component::from_file(engine, &entry.path) .map_err(Error::from) .with_context(|| format!("compile {}", entry.path.display()))?; // Enforce the scoped-transport capability set: `registry` is the - // adapter registry, so a declaration of any core-only interface + // provider registry, so a declaration of any core-only interface // fails at manifest load, and an undeclared transport import fails - // here. The linker withholds the same core-only interfaces, so an - // adapter reaching for one also fails to instantiate below. + // here. The linker withholds the same core-only interfaces, so a + // provider reaching for one also fails to instantiate. manifest::enforce_capabilities( &loaded_manifest, component.component_type().imports(engine).map(|(n, _)| n), @@ -737,29 +787,31 @@ impl Supervisor { ) .with_context(|| format!("capability violation in {}", entry.path.display()))?; - let adapter_namespace = if loaded_manifest.manifest.module.name.is_empty() { - "adapter".to_owned() + let namespace = if loaded_manifest.manifest.module.name.is_empty() { + "provider".to_owned() } else { loaded_manifest.manifest.module.name.clone() }; info!( - adapter = %adapter_namespace, + provider = %namespace, + kind = kind.kind(), fuel = limits_cfg.fuel(), memory_bytes = limits_cfg.memory(), http_allow = entry.http_allow.len(), messaging_topics = entry.messaging_topics.len(), - "applied adapter resource limits and transport scope", + "applied provider resource limits and transport scope", ); - let run = RunId::new(adapter_namespace.clone(), 0); - // An adapter store cannot call the client face, so it carries an + let linker = build_provider_linker::(engine, kind.as_ref())?; + let run = RunId::new(namespace.clone(), 0); + // A provider store cannot call the client face, so it carries an // empty registry; this also keeps the real registry out of the - // adapter's `HostState`, so there is no reference cycle back into + // provider's `HostState`, so there is no reference cycle back into // the registry that owns it. - let mut store = Self::build_store( + let store = Self::build_store( engine, components, - run.clone(), + run, entry.http_allow.clone(), limits_cfg.http(), entry.messaging_topics.clone(), @@ -771,43 +823,24 @@ impl Supervisor { VenueRegistry::empty(), services, )?; - let bindings = VenueAdapter::instantiate_async(&mut store, &component, linker) - .await - .map_err(Error::from) - .with_context(|| format!("instantiate {}", entry.path.display()))?; let config: Config = if loaded_manifest.config.is_empty() { - vec![("name".into(), adapter_namespace.clone())] + vec![("name".into(), namespace)] } else { loaded_manifest.config.clone() }; - let init_succeeded = match bindings - .call_init(&mut store, &config) - .await - .map_err(Error::from)? - { - Ok(()) => { - info!(adapter = %adapter_namespace, "adapter init succeeded"); - true - } - Err(e) => { - warn!( - adapter = %adapter_namespace, - kind = crate::host::error::fault_label(&e), - message = crate::host::error::fault_message(&e), - "adapter init failed - loaded but marked dead", - ); - false - } - }; - // Refuel after init so the first routed call starts with a full budget. - store.set_fuel(limits_cfg.fuel())?; - - Ok(LoadedAdapter { - venue_id: VenueId::from(adapter_namespace), - actor: VenueActor::new(store, bindings, limits_cfg.fuel()), - alive: init_succeeded, - }) + kind.install( + ProviderInstance { + component: &component, + linker: &linker, + store, + config, + fuel_per_call: limits_cfg.fuel(), + }, + service, + ) + .await + .with_context(|| format!("install {}", entry.path.display())) } /// Number of modules currently loaded. @@ -1464,27 +1497,20 @@ pub fn build_linker( Ok(linker) } -/// Build a `Linker` for the `venue-adapter` world: only the scoped -/// transport an adapter may reach - `chain`, `messaging`, and the -/// allowlisted `wasi:http` - plus the ambient WASI base. The core -/// `nexum:host` interfaces an adapter must not touch (local-store, -/// remote-store, identity, logging) are deliberately withheld, so an -/// adapter that imports one of them fails to instantiate rather than -/// silently gaining reach. Extensions are not linked into adapters: an -/// adapter speaks its venue's protocol over the standard transport, not a -/// domain extension surface. -pub fn build_adapter_linker( +/// Build a `Linker` for one provider kind: the kind's own scoped imports +/// plus the ambient WASI base and the allowlisted `wasi:http`. The core +/// `nexum:host` interfaces a provider must not touch (local-store, +/// remote-store, identity, logging) are deliberately withheld, so a +/// provider that imports one of them fails to instantiate rather than +/// silently gaining reach. Extensions are not linked into providers: a +/// provider speaks its protocol over the standard transport, not a domain +/// extension surface. +pub fn build_provider_linker( engine: &Engine, + kind: &dyn ProviderKind, ) -> anyhow::Result>> { let mut linker = Linker::>::new(engine); - nexum::host::chain::add_to_linker::, HasSelf>>( - &mut linker, - |state| state, - )?; - nexum::host::messaging::add_to_linker::, HasSelf>>( - &mut linker, - |state| state, - )?; + kind.link(&mut linker)?; wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; Ok(linker) diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 6d814a16..68093413 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -646,13 +646,14 @@ impl crate::host::venue_registry::VenueInvoker for ScriptedAdapter { /// Build a registry with one scripted adapter installed under `cow`. fn scripted_registry(adapter: ScriptedAdapter) -> crate::host::venue_registry::VenueRegistry { - let mut builder = crate::host::venue_registry::VenueRegistryBuilder::new( + let registry = crate::host::venue_registry::VenueRegistryBuilder::new( crate::host::venue_registry::SubmitQuota::default(), - ); - builder + ) + .build(); + registry .install(crate::host::venue_registry::VenueId::from("cow"), adapter) .expect("install"); - builder.build() + registry } /// Write a manifest subscribing the example module to intent-status @@ -2825,15 +2826,18 @@ fn chainlog_cursor_key_differs_by_each_input() { // ── venue-adapter boot ──────────────────────────────────────────────── -/// The venue-adapter linker binds only the scoped transport (chain, -/// messaging, wasi base, allowlisted http) and withholds the core-only -/// interfaces. Assembling it proves the scope wires without a +/// The venue-adapter provider linker binds only the scoped transport +/// (chain, messaging, wasi base, allowlisted http) and withholds the +/// core-only interfaces. Assembling it proves the scope wires without a /// duplicate-definition clash between the shared `nexum:host` interfaces. #[tokio::test] -async fn adapter_linker_assembles_with_scoped_transport() { +async fn provider_linker_assembles_with_scoped_transport() { let engine = make_wasmtime_engine(); - crate::supervisor::build_adapter_linker::(&engine) - .expect("adapter linker assembles"); + crate::supervisor::build_provider_linker::( + &engine, + &crate::host::venue_registry::VenueAdapterKind, + ) + .expect("provider linker assembles"); } /// The module-kind discriminator gates the adapter load path: an @@ -2876,6 +2880,41 @@ async fn boot_rejects_adapter_whose_manifest_is_an_event_module() { ); } +/// A kind spelling no extension registered is refused at boot with a +/// message naming the registered kinds. +#[tokio::test] +async fn boot_rejects_an_unregistered_provider_kind() { + let engine = make_wasmtime_engine(); + let components = crate::test_utils::mock_components(); + let linker = crate::supervisor::build_linker::(&engine, &[]) + .expect("build_linker"); + + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("module.toml"); + std::fs::write(&manifest, "[module]\nname = \"bad\"\nkind = \"gadget\"\n") + .expect("write manifest"); + + let config = EngineConfig { + adapters: vec![crate::engine_config::AdapterEntry { + path: dir.path().join("gadget.wasm"), + manifest: Some(manifest), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + ..Default::default() + }; + + let err = match Supervisor::boot(&engine, &linker, &config, &components, &[], None).await { + Ok(_) => panic!("an unregistered provider kind must be refused"), + Err(err) => err, + }; + let msg = format!("{err:#}"); + assert!( + msg.contains("unregistered provider kind gadget") && msg.contains("venue-adapter"), + "the refusal names the unknown spelling and the registered kinds: {msg}", + ); +} + /// A venue-adapter manifest clears the discriminator; boot then reaches the /// compile step and fails only because the referenced wasm is absent. This /// proves the discriminator routed the entry to the adapter load path