Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ anyhow = "1"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
futures = "0.3"
# Cold `dyn` boot paths only (`ProviderKind::install`); hot guest traits
# use native async-fn-in-trait.
async-trait = "0.1"

# Serde + config.
serde = { version = "1", features = ["derive"] }
Expand Down
1 change: 1 addition & 0 deletions crates/nexum-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ wasmtime-wasi-http.workspace = true
# Async + error plumbing.
anyhow.workspace = true
thiserror.workspace = true
async-trait.workspace = true
# `strum::IntoStaticStr` on error enums gives metric labels (`error_kind`)
# free via a snake_case `&'static str` for every variant. Used at
# `tracing::warn!(error_kind = <variant_name>.into(), ...)` sites and
Expand Down
3 changes: 2 additions & 1 deletion crates/nexum-runtime/src/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
//! [`LaunchRuntime`] directly.

use std::path::Path;
use std::sync::Arc;

use crate::addons::RuntimeAddOn;
use crate::builder::{AssembledRuntime, LaunchContext, LaunchRuntime};
Expand All @@ -34,7 +35,7 @@ pub async fn run<T: RuntimeTypes>(
wasm: Option<&Path>,
manifest: Option<&Path>,
components: &Components<T>,
extensions: &[Extension<T>],
extensions: &[Arc<dyn Extension<T>>],
add_ons: &[&dyn RuntimeAddOn],
) -> anyhow::Result<()> {
let runtime = AssembledRuntime {
Expand Down
26 changes: 15 additions & 11 deletions crates/nexum-runtime/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use std::future::{Future, IntoFuture};
use std::marker::PhantomData;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;

use nexum_tasks::{DrainOutcome, TaskExit, TaskHandle, TaskManager, TaskSet};
Expand Down Expand Up @@ -127,8 +128,9 @@ fn finish_wait(joined: Option<TaskExit>) -> anyhow::Result<()> {
pub struct AssembledRuntime<'a, T: RuntimeTypes> {
/// Shared backends threaded into every module store.
pub components: Components<T>,
/// Linker hooks and capability namespaces.
pub extensions: Vec<Extension<T>>,
/// Extensions: namespaces, capabilities, linker hooks, services, and
/// provider kinds.
pub extensions: Vec<Arc<dyn Extension<T>>>,
/// Cross-cutting facilities installed before the engine boots.
pub add_ons: &'a [&'a dyn RuntimeAddOn],
/// Single-module source override; `None` runs `[[modules]]`.
Expand Down Expand Up @@ -386,19 +388,18 @@ impl<'a> RuntimeBuilder<'a> {
/// optional extension hooks and module source before [`launch`](Self::launch).
pub struct PresetBuilder<'a, R: Runtime> {
config: &'a EngineConfig,
extensions: Vec<Extension<R::Types>>,
extensions: Vec<Arc<dyn Extension<R::Types>>>,
wasm: Option<PathBuf>,
manifest: Option<PathBuf>,
clocks: Option<WasiClockOverride>,
_r: PhantomData<fn() -> R>,
}

impl<'a, R: Runtime> PresetBuilder<'a, R> {
/// Add extension linker hooks and capability namespaces on top of the
/// preset. The default preset carries none.
/// Add extensions on top of the preset. The default preset carries none.
pub fn with_extensions(
mut self,
extensions: impl IntoIterator<Item = Extension<R::Types>>,
extensions: impl IntoIterator<Item = Arc<dyn Extension<R::Types>>>,
) -> Self {
self.extensions.extend(extensions);
self
Expand Down Expand Up @@ -459,16 +460,19 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> {
/// may be added before the component builders.
pub struct TypedBuilder<'a, T: RuntimeTypes> {
config: &'a EngineConfig,
extensions: Vec<Extension<T>>,
extensions: Vec<Arc<dyn Extension<T>>>,
wasm: Option<PathBuf>,
manifest: Option<PathBuf>,
clocks: Option<WasiClockOverride>,
_t: PhantomData<fn() -> T>,
}

impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> {
/// Add the extension linker hooks and capability namespaces.
pub fn with_extensions(mut self, extensions: impl IntoIterator<Item = Extension<T>>) -> Self {
/// Add the extensions.
pub fn with_extensions(
mut self,
extensions: impl IntoIterator<Item = Arc<dyn Extension<T>>>,
) -> Self {
self.extensions.extend(extensions);
self
}
Expand Down Expand Up @@ -509,7 +513,7 @@ impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> {
/// The component builders are bound; the add-on set remains.
pub struct ComponentsStage<'a, T: RuntimeTypes, C, S, E> {
config: &'a EngineConfig,
extensions: Vec<Extension<T>>,
extensions: Vec<Arc<dyn Extension<T>>>,
wasm: Option<PathBuf>,
manifest: Option<PathBuf>,
clocks: Option<WasiClockOverride>,
Expand All @@ -536,7 +540,7 @@ impl<'a, T: RuntimeTypes, C, S, E> ComponentsStage<'a, T, C, S, E> {
/// runs.
pub struct ReadyBuilder<'a, T: RuntimeTypes, C, S, E> {
config: &'a EngineConfig,
extensions: Vec<Extension<T>>,
extensions: Vec<Arc<dyn Extension<T>>>,
wasm: Option<PathBuf>,
manifest: Option<PathBuf>,
clocks: Option<WasiClockOverride>,
Expand Down
209 changes: 183 additions & 26 deletions crates/nexum-runtime/src/host/extension.rs
Original file line number Diff line number Diff line change
@@ -1,39 +1,196 @@
//! The extension seam: a linker hook plus the capability namespace an
//! extension contributes, assembled at the composition root and threaded
//! into every module linker.
//! The extension seam: what one extension contributes to the host - a
//! namespace, a capability namespace, a linker hook, an optional host
//! service, and an optional provider kind. Assembled at the composition
//! root and threaded into every module linker.

use std::any::Any;
use std::collections::BTreeMap;
use std::sync::Arc;

use wasmtime::component::Linker;
use async_trait::async_trait;
use wasmtime::Store;
use wasmtime::component::{Component, Linker};

use crate::host::component::RuntimeTypes;
use crate::host::state::HostState;
use crate::manifest::NamespaceCaps;

/// Adds an extension's WIT interfaces to a module linker. Runs after the
/// core interfaces and before instantiation. Takes only `&mut Linker`, so
/// the seam stays compatible with a future per-extension router that
/// serialises access to the non-`Sync` wasmtime `Store`.
pub type LinkerHook<T> = Arc<dyn Fn(&mut Linker<HostState<T>>) -> anyhow::Result<()> + Send + Sync>;

/// One runtime extension: how to wire its interfaces into a module linker,
/// and the capability namespace enforcement must recognise for it. The two
/// travel together: a module that imports an extension interface boots only
/// if the linker entry AND the capability namespace are both registered
/// before instantiation.
pub struct Extension<T: RuntimeTypes> {
/// Linker contribution: adds the extension's imports to a module linker.
pub link: LinkerHook<T>,
/// Capability namespace this extension owns, merged into enforcement so
/// a module importing the extension's interfaces still validates.
pub capabilities: NamespaceCaps,
/// One runtime extension. A module that imports an extension interface
/// boots only if the linker entry AND the capability namespace are both
/// registered before instantiation.
pub trait Extension<T: RuntimeTypes>: Send + Sync + 'static {
/// Namespace this extension owns; keys its service in [`HostServices`].
fn namespace(&self) -> &'static str;

/// Capability namespace merged into enforcement so a module importing
/// the extension's interfaces still validates.
fn capabilities(&self) -> NamespaceCaps;

/// Adds the extension's imports to a worker linker. Runs after the
/// core interfaces and before instantiation. Takes only `&mut Linker`,
/// so the seam stays compatible with a future per-extension router
/// that serializes access to the non-`Sync` wasmtime `Store`.
fn link(&self, linker: &mut Linker<HostState<T>>) -> anyhow::Result<()>;

/// Host service this extension owns, published under its namespace on
/// [`HostServices`].
fn service(&self) -> Option<Arc<dyn HostService>> {
None
}

/// Provider kind this extension installs.
fn provider(&self) -> Option<Box<dyn ProviderKind<T>>> {
None
}
}

/// A type-erased host service an extension owns. Held per namespace on
/// `HostState::services` and downcast at the call site. Kept synchronous
/// so it stays `dyn`-compatible.
pub trait HostService: Any + Send + Sync + 'static {}

/// A provider component kind: the host holds an instance behind the owning
/// extension's serialized service; others call it. `async_trait` carries
/// the one cold `dyn` boot path until `async_fn_in_dyn_trait` stabilizes.
#[async_trait]
pub trait ProviderKind<T: RuntimeTypes>: Send + Sync + 'static {
/// Manifest kind this provider answers for.
fn kind(&self) -> &'static str;

/// Adds the provider's imports to a provider linker.
fn link(&self, linker: &mut Linker<HostState<T>>) -> anyhow::Result<()>;

/// Install one instantiated provider behind the extension's service.
async fn install(
&self,
component: &Component,
store: Store<HostState<T>>,
service: &Arc<dyn HostService>,
) -> anyhow::Result<()>;
}

/// Immutable per-namespace service map: each extension's [`HostService`]
/// under its [`Extension::namespace`], built once at boot and shared by
/// every module store.
#[derive(Clone, Default)]
pub struct HostServices(Arc<BTreeMap<&'static str, Arc<dyn HostService>>>);

impl std::fmt::Debug for HostServices {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_set().entries(self.0.keys()).finish()
}
}

impl HostServices {
/// Collect each extension's service under its namespace. Refuses a
/// duplicate namespace.
pub fn from_extensions<T: RuntimeTypes>(
extensions: &[Arc<dyn Extension<T>>],
) -> anyhow::Result<Self> {
let mut map = BTreeMap::new();
for ext in extensions {
let Some(service) = ext.service() else {
continue;
};
let namespace = ext.namespace();
if map.insert(namespace, service).is_some() {
anyhow::bail!("duplicate extension service namespace {namespace}");
}
}
Ok(Self(Arc::new(map)))
}

/// The service under `namespace`, downcast to its concrete type.
/// `None` when the namespace is absent or the type does not match.
pub fn get<S: HostService>(&self, namespace: &str) -> Option<Arc<S>> {
let service = Arc::clone(self.0.get(namespace)?);
let erased: Arc<dyn Any + Send + Sync> = service;
erased.downcast().ok()
}

/// The raw type-erased service under `namespace`.
pub fn raw(&self, namespace: &str) -> Option<&Arc<dyn HostService>> {
self.0.get(namespace)
}
}

impl<T: RuntimeTypes> Clone for Extension<T> {
fn clone(&self) -> Self {
Self {
link: Arc::clone(&self.link),
capabilities: self.capabilities,
#[cfg(test)]
mod tests {
use super::*;
use crate::supervisor::TestTypes;

struct Registry(u64);
impl HostService for Registry {}

struct Clockwork;
impl HostService for Clockwork {}

struct ServiceExt {
namespace: &'static str,
service: Option<Arc<dyn HostService>>,
}

impl Extension<TestTypes> for ServiceExt {
fn namespace(&self) -> &'static str {
self.namespace
}
fn capabilities(&self) -> NamespaceCaps {
NamespaceCaps {
prefix: "test:ext/",
ifaces: &[],
}
}
fn link(&self, _linker: &mut Linker<HostState<TestTypes>>) -> anyhow::Result<()> {
Ok(())
}
fn service(&self) -> Option<Arc<dyn HostService>> {
self.service.as_ref().map(Arc::clone)
}
}

fn ext(
namespace: &'static str,
service: Arc<dyn HostService>,
) -> Arc<dyn Extension<TestTypes>> {
Arc::new(ServiceExt {
namespace,
service: Some(service),
})
}

/// A registered service comes back under its namespace, downcast to
/// its concrete type; a wrong type or an absent namespace is `None`.
#[test]
fn get_downcasts_by_namespace() {
let services =
HostServices::from_extensions(&[ext("videre", Arc::new(Registry(7)))]).expect("build");

let registry = services.get::<Registry>("videre").expect("registered");
assert_eq!(registry.0, 7);
assert!(services.get::<Clockwork>("videre").is_none());
assert!(services.get::<Registry>("absent").is_none());
assert!(services.raw("videre").is_some());
}

/// A serviceless extension contributes nothing to the map.
#[test]
fn serviceless_extension_is_absent() {
let serviceless: Arc<dyn Extension<TestTypes>> = Arc::new(ServiceExt {
namespace: "quiet",
service: None,
});
let services = HostServices::from_extensions(&[serviceless]).expect("build");
assert!(services.raw("quiet").is_none());
}

/// Two services under one namespace refuse to build.
#[test]
fn duplicate_namespace_is_refused() {
let err = HostServices::from_extensions(&[
ext("videre", Arc::new(Registry(1))),
ext("videre", Arc::new(Clockwork)),
])
.expect_err("duplicate namespace");
assert!(err.to_string().contains("videre"), "{err}");
}
}
4 changes: 4 additions & 0 deletions crates/nexum-runtime/src/host/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
use wasmtime_wasi_http::WasiHttpCtx;

use super::component::{Handle, RuntimeTypes};
use super::extension::HostServices;
use super::http::HttpGate;
use super::logs::{LogRouter, RunId};
use super::venue_registry::VenueRegistry;
Expand Down Expand Up @@ -55,6 +56,9 @@ pub struct HostState<T: RuntimeTypes> {
/// Every module store carries the same shared handle; an adapter store,
/// which cannot call the client face, carries an empty one.
pub venue_registry: VenueRegistry,
/// Extension-owned host services, keyed by extension namespace and
/// downcast at the call site. One shared map across every store.
pub services: HostServices,
}

// `WasiView: Send`, so the backends must be `Send` too; the lattice
Expand Down
Loading
Loading