Compare commits

...

5 Commits

49 changed files with 1160 additions and 38 deletions
+8
View File
@@ -1,4 +1,12 @@
//! Example service that imports the versioned Wasmeld monotonic-clock capability.
//!
//! Each response is eight little-endian timestamp bytes followed by the input.
//! The timestamp is Actor-local monotonic time, not a Unix timestamp.
mod bindings {
// This macro generates Rust ABI adapters at compile time from the
// materialized local WIT graph. It does not make a remote protocol call or
// embed the WIT source files; the Component carries canonical ABI types.
wit_bindgen::generate!({
path: "wit",
world: "clock-probe-component",
+2 -2
View File
@@ -4,12 +4,12 @@ schema_version = 1
name = "wasmeld:clock"
version = "0.1.0"
source = "path+../../wit/clock"
sha256 = "93079c69d1b9cb3afc28da6fe4a7e37f724d0a09e780c847f8524d3920a90960"
sha256 = "43ece485a550eca94cf06cb711f0b42c9f4977d275e9a042c3ac84eeb1f941e1"
replaced = true
[[package]]
name = "wasmeld:service"
version = "0.1.0"
source = "path+../../wit/service"
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
replaced = true
+2
View File
@@ -1,5 +1,7 @@
package component:clock-probe@0.1.0;
/// Base service exports plus the exact monotonic-clock Host capability used by
/// this Component. Other Wasmeld capabilities are not linked implicitly.
world clock-probe-component {
include wasmeld:service/service-component@0.1.0;
import wasmeld:clock/monotonic-clock@0.1.0;
+8
View File
@@ -1,6 +1,14 @@
//! Minimal stateful Component used to demonstrate a warm Actor Store.
//!
//! Invoke returns an eight-byte little-endian counter. The value survives
//! calls to the same Actor but resets on `init`, restart, or process recovery.
use core::sync::atomic::{AtomicU64, Ordering};
mod bindings {
// Bindings are generated from `wit/world.wit` plus materialized
// `wit/deps`; WIT is a compile-time ABI contract, not downloaded at run
// time and not executed as code.
wit_bindgen::generate!({
path: "wit",
world: "counter-component",
+1 -1
View File
@@ -4,5 +4,5 @@ schema_version = 1
name = "wasmeld:service"
version = "0.1.0"
source = "path+../../wit/service"
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
replaced = true
+1
View File
@@ -1,5 +1,6 @@
package component:counter@0.1.0;
/// Counter only needs the base service lifecycle and imports no Host capability.
world counter-component {
include wasmeld:service/service-component@0.1.0;
}
+4
View File
@@ -1,4 +1,8 @@
//! Smallest request/response Component example: it returns input bytes unchanged.
mod bindings {
// Generate the guest trait and export adapter from the selected WIT world.
// Dependency sync must populate `wit/deps` before this macro is compiled.
wit_bindgen::generate!({
path: "wit",
world: "echo-component",
+1 -1
View File
@@ -4,5 +4,5 @@ schema_version = 1
name = "wasmeld:service"
version = "0.1.0"
source = "path+../../wit/service"
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
replaced = true
+1
View File
@@ -1,5 +1,6 @@
package component:echo@0.1.0;
/// Echo only needs the base service lifecycle and imports no Host capability.
world echo-component {
include wasmeld:service/service-component@0.1.0;
}
+8
View File
@@ -1,4 +1,10 @@
//! Deliberately trapping Component used to verify Actor fault isolation.
//!
//! Sending the exact bytes `fault` triggers a panic. This crate is a test
//! fixture and must not be deployed as an application service.
mod bindings {
// Compile the versioned WIT world into canonical ABI Rust bindings.
wit_bindgen::generate!({
path: "wit",
world: "fault-component",
@@ -13,6 +19,8 @@ impl bindings::Guest for Fault {
}
fn invoke(input: Vec<u8>) -> Result<Vec<u8>, bindings::ServiceError> {
// A panic becomes a Wasm trap so Runtime tests can assert that one
// faulted Actor does not corrupt other service instances.
assert_ne!(input, b"fault", "intentional test fault");
Ok(input)
}
+1 -1
View File
@@ -4,5 +4,5 @@ schema_version = 1
name = "wasmeld:service"
version = "0.1.0"
source = "path+../../wit/service"
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
replaced = true
+1
View File
@@ -1,5 +1,6 @@
package component:fault@0.1.0;
/// Fault fixture exports only the base service lifecycle.
world fault-component {
include wasmeld:service/service-component@0.1.0;
}
+8
View File
@@ -1,4 +1,12 @@
//! Example service that exercises persistent, service-scoped Host KV.
//!
//! Input uses the test protocol `get:<key>`, `set:<key>:<value>`, or
//! `delete:<key>`. Production Components should define an application-specific
//! request format instead of copying this colon-delimited probe protocol.
mod bindings {
// `generate_all` emits both the service export and imported KV module from
// the exact versions selected in `wit/world.wit`.
wit_bindgen::generate!({
path: "wit",
world: "kv-probe-component",
+2 -2
View File
@@ -4,12 +4,12 @@ schema_version = 1
name = "wasmeld:kv"
version = "0.1.0"
source = "path+../../wit/kv"
sha256 = "6dc3a84dc03c7104aa8b305518466c95e4440e1031c4bef3b59843f40f24d0fb"
sha256 = "10dc81b1f04bd9c8f7b324a96e0c1d24d5af0c2dabfc723d003b8729cfd933d9"
replaced = true
[[package]]
name = "wasmeld:service"
version = "0.1.0"
source = "path+../../wit/service"
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
replaced = true
+2
View File
@@ -1,5 +1,7 @@
package component:kv-probe@0.1.0;
/// Base service exports plus the exact persistent KV Host capability used by
/// this Component.
world kv-probe-component {
include wasmeld:service/service-component@0.1.0;
import wasmeld:kv/store@0.1.0;
+10
View File
@@ -1,6 +1,14 @@
//! Resident event/effect fixture covering every current Host-driven I/O family.
//!
//! The callback echoes stream chunks and datagrams, rearms fired timers,
//! acknowledges messages, and reflects extension commands. It owns no socket,
//! timer, or subscription handles; those resources remain in the Host driver.
use std::sync::atomic::{AtomicU64, Ordering};
mod bindings {
// One generated module contains both the base service export and resident
// actor export because this component world composes both WIT packages.
wit_bindgen::generate!({
path: "wit",
world: "resident-probe-component",
@@ -35,6 +43,8 @@ impl ResidentGuest for ResidentProbe {
fn handle_event(
input: Event,
) -> Result<Vec<Effect>, bindings::exports::wasmeld::resident::actor::ResidentError> {
// Event callbacks are serialized with normal invocations by the same
// Actor mailbox, so this in-memory count is deterministic per Actor.
EVENTS_HANDLED.fetch_add(1, Ordering::Relaxed);
Ok(match input {
Event::StreamData(chunk) => vec![Effect::WriteStream(StreamWrite {
+2 -2
View File
@@ -4,12 +4,12 @@ schema_version = 1
name = "wasmeld:resident"
version = "0.1.0"
source = "path+../../wit/resident"
sha256 = "683a3c7196a6dd77b3f619227aa093430ff6995c097d39bb82d501b7c59a196c"
sha256 = "a13728b0e6f000c03eb0f88e2e54bad1c6e5160ac3ca4b9e87619247c0043d1b"
replaced = true
[[package]]
name = "wasmeld:service"
version = "0.1.0"
source = "path+../../wit/service"
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
replaced = true
+2
View File
@@ -1,5 +1,7 @@
package component:resident-probe@0.1.0;
/// Composes ordinary request/response exports with the optional resident actor
/// export. Network and timer drivers remain Host-owned.
world resident-probe-component {
include wasmeld:service/service-component@0.1.0;
export wasmeld:resident/actor@0.1.0;
+7
View File
@@ -1,4 +1,11 @@
//! CPU-bound fixture used to verify fuel and epoch deadline interruption.
//!
//! The input's first eight bytes select a little-endian iteration count.
//! Initializing with `spin` intentionally runs without a finite loop bound.
//! This crate is a Runtime test fixture, not an application service.
mod bindings {
// Compile the base service WIT contract into guest and export adapters.
wit_bindgen::generate!({
path: "wit",
world: "spin-component",
+1 -1
View File
@@ -4,5 +4,5 @@ schema_version = 1
name = "wasmeld:service"
version = "0.1.0"
source = "path+../../wit/service"
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
replaced = true
+1
View File
@@ -1,5 +1,6 @@
package component:spin@0.1.0;
/// Spin fixture exports only the base service lifecycle.
world spin-component {
include wasmeld:service/service-component@0.1.0;
}
+8
View File
@@ -1,4 +1,12 @@
//! Fixture proving that ambient WASI clock imports are denied by the sandbox.
//!
//! Calling `std::time::Instant::now` causes this Component to import WASI
//! monotonic-clock directly. Wasmeld only links explicitly approved capability
//! packages, so registration or startup must reject this fixture.
mod bindings {
// The declared service WIT contains no WASI clock capability; the direct
// standard-library use below deliberately creates an undeclared import.
wit_bindgen::generate!({
path: "wit",
world: "wasi-clock-probe-component",
+1 -1
View File
@@ -4,5 +4,5 @@ schema_version = 1
name = "wasmeld:service"
version = "0.1.0"
source = "path+../../wit/service"
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
replaced = true
@@ -1,5 +1,7 @@
package component:wasi-clock-probe@0.1.0;
/// The declared world grants only the base service contract. Any direct WASI
/// clock import introduced by implementation code remains undeclared.
world wasi-clock-probe-component {
include wasmeld:service/service-component@0.1.0;
}
+22
View File
@@ -1,3 +1,12 @@
/**
* Typed management-plane client for `wasmeld-console`.
*
* These DTOs intentionally preserve the Rust API's snake_case fields; view
* formatting belongs in `console-model.ts`. Management invocation bytes use
* base64 inside JSON for inspectability. Public clients should call the
* gateway's `application/octet-stream` endpoint instead.
*/
export const DEFAULT_API_BASE = import.meta.env.VITE_WASMELD_API_URL ?? "http://127.0.0.1:8080";
const API_BASE_STORAGE_KEY = "wasmeld-api-base";
@@ -80,6 +89,8 @@ export type InvokeResult = {
};
export function getApiBase(): string {
// TanStack Start can evaluate modules during SSR, where localStorage does
// not exist. The environment default keeps server rendering deterministic.
if (typeof window === "undefined") return DEFAULT_API_BASE;
return window.localStorage.getItem(API_BASE_STORAGE_KEY) ?? DEFAULT_API_BASE;
}
@@ -95,6 +106,9 @@ export function saveApiBase(value: string): string {
}
export async function fetchSnapshot(apiBase: string) {
// Parallel reads minimize refresh latency but are not a database
// transaction. A deployment can change between responses; the five-second
// poll in the route reconciles that short-lived inconsistency.
const [services, deployments, events, runtime, witPackages] = await Promise.all([
request<{ services: BackendService[] }>(apiBase, "/api/v1/services"),
request<{ deployments: BackendDeployment[] }>(apiBase, "/api/v1/deployments"),
@@ -181,6 +195,9 @@ export async function invokeComponent(
service: Pick<BackendService, "id" | "revision">,
input: Uint8Array,
): Promise<InvokeResult> {
// Example: `invokeComponent(base, service, new TextEncoder().encode("ping"))`.
// The management endpoint targets an exact revision and is intended for the
// operator console, not public application traffic.
const response = await request<{
output_base64: string;
output_bytes: number;
@@ -216,6 +233,9 @@ async function request<T>(apiBase: string, path: string, init?: RequestInit): Pr
}
if (!response.ok) {
// Both management and gateway errors use `{ error: { code, message } }`.
// The UI displays only the server-safe message and falls back to status
// when a proxy returns HTML or an otherwise unrelated response.
const body = (await response.json().catch(() => null)) as {
error?: { message?: string };
} | null;
@@ -225,6 +245,8 @@ async function request<T>(apiBase: string, path: string, init?: RequestInit): Pr
}
function bytesToBase64(bytes: Uint8Array): string {
// Invocation input is bounded by the Runtime, so constructing this binary
// string cannot grow beyond the configured management request limit.
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return window.btoa(binary);
+16
View File
@@ -1,3 +1,11 @@
/**
* Pure conversion and presentation helpers for the management UI.
*
* No function in this module performs I/O or changes backend state. Keeping
* byte decoding here prevents React views from silently treating arbitrary
* Component output as readable text.
*/
import { FileCode2, History, LayoutDashboard, Package, Server, Settings } from "lucide-react";
import { BackendCapability, BackendEvent, BackendService } from "./api";
@@ -160,6 +168,9 @@ export function decodeReadableUtf8(bytes: Uint8Array) {
return null;
}
// Valid UTF-8 can still contain terminal controls. Reject them so the output
// panel cannot render invisible or misleading content; tab/newline/CR remain
// useful for ordinary textual responses.
for (const character of text) {
const codePoint = character.codePointAt(0) ?? 0;
const allowedWhitespace = codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d;
@@ -175,6 +186,9 @@ export function formatInvocationOutput(
output: Uint8Array,
format: "utf8" | "hex",
) {
// Counter is the one built-in example with a documented numeric wire
// format. Other eight-byte services remain generic bytes and are not
// guessed as integers.
if (service.id === "counter" && output.byteLength === 8) {
return {
text: new DataView(output.buffer, output.byteOffset, output.byteLength)
@@ -192,6 +206,8 @@ export function formatInvocationOutput(
} satisfies FormattedInvocationOutput;
}
// UTF-8 decoding is fatal rather than replacement-based. If one byte is
// invalid or contains a disallowed control, show the exact bytes as HEX.
const text = decodeReadableUtf8(output);
if (text !== null) {
return {
+6
View File
@@ -119,6 +119,9 @@ function Home() {
setEvents(snapshot.events.map(toEvent));
setRuntime(snapshot.runtime);
setWitPackages(snapshot.witPackages);
// Preserve operator context when possible. Registrations and cleanup can
// remove the selected revision between polls, in which case the first
// remaining service becomes the deterministic fallback.
setSelectedKey((current) =>
nextServices.some((service) => serviceKey(service) === current)
? current
@@ -128,6 +131,9 @@ function Home() {
);
setConnection("online");
} catch {
// Clear the complete snapshot on any request failure. Mixing stale
// services with live deployments or Runtime state would present actions
// against identities that may no longer exist.
setServices([]);
setDeployments([]);
setEvents([]);
+36 -1
View File
@@ -1,4 +1,14 @@
//! Axum management and public gateway protocol adapters.
//!
//! The management router accepts JSON and multipart packages and exposes
//! lifecycle operations. The gateway router is intentionally smaller: it
//! accepts raw `application/octet-stream` input and returns raw bytes plus
//! `x-wasmeld-revision` and `x-wasmeld-latency-ms` response headers.
//!
//! Neither router installs authentication or authorization middleware. A
//! production deployment must place the appropriate identity, authorization,
//! TLS, rate-limit, and request-size policy in front of these routers. CORS is
//! a browser access policy and must not be treated as authentication.
use super::*;
use axum::{
@@ -21,6 +31,11 @@ use wasmeld_package::wit_package::WitPackageError;
///
/// The returned router exposes Runtime control, component registration,
/// invocation, events, and WIT Registry endpoints.
///
/// `allowed_origins` only affects browser CORS responses. Passing an empty
/// vector does not make the API private; it merely omits allowed origins.
/// Multipart bodies receive one MiB of framing headroom above the larger
/// configured artifact limit.
pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
let max_body_bytes = console
.max_artifact_bytes()
@@ -89,7 +104,18 @@ pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
/// Builds the public data-plane API around the same resident Runtime.
///
/// This router intentionally contains no registration, lifecycle, Registry,
/// or deployment management routes.
/// or deployment management routes. Callers must send
/// `Content-Type: application/octet-stream`; the stable service ID is resolved
/// to its active immutable revision at the start of each request.
///
/// # Wire example
///
/// ```text
/// POST /v1/services/image-resize/invoke
/// Content-Type: application/octet-stream
///
/// <component-specific bytes>
/// ```
pub fn gateway_app(console: Arc<Console>) -> Router {
let max_input_bytes = console.max_gateway_input_bytes();
Router::new()
@@ -126,6 +152,9 @@ async fn gateway_invoke(
let input = body.map_err(|_| GatewayError::PayloadTooLarge {
limit: console.max_gateway_input_bytes(),
})?;
// Deployment resolution and Actor enqueue happen in the same blocking
// closure. The returned revision header therefore identifies the exact
// revision that received this request, even if deployment changes later.
let invocation = run_blocking(console, move |console| {
console.gateway_invoke(&service_id, input.to_vec())
})
@@ -262,6 +291,9 @@ async fn publish_wit_package(
State(console): State<Arc<Console>>,
mut multipart: Multipart,
) -> Result<(StatusCode, Json<WitPackageMetadata>), ApiError> {
// Axum materializes this one field in memory. DefaultBodyLimit above is
// only a transport guard; WitRegistry repeats the exact artifact limit and
// validates the package identity before committing it to disk.
let mut package = None;
while let Some(field) = multipart
.next_field()
@@ -453,6 +485,9 @@ where
// an operation may have changed state before returning its error.
let operation_console = Arc::clone(&console);
let result = run_blocking(operation_console, operation).await;
// The flush marker orders previously accepted metric deltas before the
// full snapshot transaction. It means "attempted", not "durably written";
// the telemetry worker logs write failures independently.
console.flush_invocation_telemetry().await;
let persisted = console.persist_snapshot().await;
@@ -1,4 +1,10 @@
//! Bounded, asynchronous persistence for data-plane invocation telemetry.
//!
//! Invocation responses must not wait for libSQL, so producers use
//! [`tokio::sync::mpsc::Sender::try_send`] and one task writes ordered batches.
//! When the queue is full or closed, the update is deliberately dropped and a
//! warning is emitted. These counters and events are operational telemetry;
//! they are not suitable as a billing ledger or audit log.
use std::sync::Arc;
@@ -10,6 +16,10 @@ const QUEUE_CAPACITY: usize = 1024;
const MAX_BATCH_SIZE: usize = 64;
/// Non-blocking producer used by Wasmtime invocation threads.
///
/// All producers share one FIFO queue. Queue order is preserved for accepted
/// records, but a process crash can lose records that have not yet reached
/// libSQL.
pub(crate) struct InvocationPersistence {
sender: mpsc::Sender<Command>,
}
@@ -21,6 +31,10 @@ enum Command {
impl InvocationPersistence {
/// Starts the single ordered writer on the current Tokio runtime.
///
/// `sync` is shared with full Console snapshot persistence. Taking it
/// around each batch prevents a snapshot transaction from racing a metric
/// delta transaction.
pub(crate) fn start(persistence: Persistence, sync: Arc<Mutex<()>>) -> Self {
let (sender, mut receiver) = mpsc::channel(QUEUE_CAPACITY);
tokio::spawn(async move {
@@ -45,6 +59,10 @@ impl InvocationPersistence {
tracing::error!(%error, "failed to persist invocation telemetry");
}
}
// Flush is an ordering barrier, not a durability receipt. A
// database error above is logged rather than returned, and a
// flush may wait for records dequeued after the marker when
// those records fit in the same batch.
for flush in flushes {
let _ = flush.send(());
}
@@ -54,6 +72,9 @@ impl InvocationPersistence {
}
/// Enqueues telemetry without extending API response latency.
///
/// Saturation drops this update instead of applying backpressure to the
/// Component invocation path.
pub(crate) fn record(&self, update: InvocationUpdate) {
if let Err(error) = self.sender.try_send(Command::Record(update)) {
tracing::warn!(%error, "invocation telemetry queue is unavailable; update dropped");
@@ -61,6 +82,10 @@ impl InvocationPersistence {
}
/// Waits until all telemetry queued before this call has been attempted.
///
/// This method is used before a control-plane snapshot so the snapshot
/// reads the newest in-memory counters after older delta writes. It does
/// not report persistence failures; those are visible through tracing.
pub(crate) async fn flush(&self) {
let (sender, receiver) = oneshot::channel();
if self.sender.send(Command::Flush(sender)).await.is_ok() {
+32
View File
@@ -1,4 +1,18 @@
//! Bounded bridge between synchronous Component Host calls and async Toasty.
//!
//! Wasmtime calls [`wasmeld_runtime::KvBackend`] synchronously on an Actor
//! thread, while Toasty and libSQL are asynchronous. This adapter moves every
//! database operation onto one dedicated current-thread Tokio runtime. The
//! bounded channel prevents a slow database from accumulating unbounded work
//! or blocking every Actor while waiting for queue capacity.
//!
//! # Timeout semantics
//!
//! A timeout only stops the caller from waiting. It does not remove a command
//! that is already queued or cancel a libSQL operation that has started.
//! Consequently, `set` and `delete` may commit after the Component observes
//! `KvBackendError::Timeout`. Components should make retries idempotent and
//! must not interpret a timeout as proof that no mutation occurred.
use std::{
fmt, io,
@@ -15,6 +29,9 @@ use wasmeld_runtime::{KvBackend, KvBackendError};
use crate::persistence::Persistence;
const COMMAND_CAPACITY: usize = 64;
// Keep Host calls responsive even when a Component requests a larger timeout.
// The Component-facing deadline is an upper bound, not permission to occupy an
// Actor thread indefinitely.
const MAX_RESPONSE_TIMEOUT: Duration = Duration::from_millis(400);
pub(crate) struct ToastyKvBackend {
@@ -47,6 +64,11 @@ enum KvCommand {
}
impl ToastyKvBackend {
/// Starts the database worker and waits until its Tokio runtime is ready.
///
/// Waiting for the ready signal makes construction fail deterministically
/// instead of returning a backend whose first operation discovers that
/// the worker could not initialize.
pub(crate) fn start(persistence: Persistence) -> io::Result<Self> {
let (sender, receiver) = mpsc::sync_channel(COMMAND_CAPACITY);
let (ready_sender, ready_receiver) = mpsc::sync_channel(1);
@@ -78,6 +100,8 @@ impl ToastyKvBackend {
build: impl FnOnce(SyncSender<Result<T, KvBackendError>>) -> KvCommand,
) -> Result<T, KvBackendError> {
let (response, receiver) = mpsc::sync_channel(1);
// `try_send` is intentional: waiting for queue capacity here would
// stall the Actor and defeat the Runtime's invocation deadline.
match self.inner.sender.try_send(build(response)) {
Ok(()) => {}
Err(TrySendError::Full(_)) => return Err(KvBackendError::Busy),
@@ -149,6 +173,9 @@ impl KvBackend for ToastyKvBackend {
impl Drop for BackendInner {
fn drop(&mut self) {
// Only the final Arc owner reaches this Drop. Joining guarantees the
// worker no longer holds the Persistence handle when Console shuts
// down. If commands precede Shutdown, the FIFO channel drains them.
let _ = self.sender.send(KvCommand::Shutdown);
if let Ok(worker) = self.worker.get_mut()
&& let Some(worker) = worker.take()
@@ -163,6 +190,8 @@ fn run_worker(
receiver: Receiver<KvCommand>,
ready: SyncSender<io::Result<()>>,
) {
// Toasty is driven from one thread so synchronous Host calls never need to
// enter or block the Console server's multi-thread Tokio runtime.
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
@@ -175,6 +204,9 @@ fn run_worker(
};
let _ = ready.send(Ok(()));
// A single consumer also defines the ordering of mutations submitted to
// this backend. Ordering across different Wasmeld processes is delegated
// to libSQL and is not guaranteed by this queue.
while let Ok(command) = receiver.recv() {
match command {
KvCommand::Get {
+102 -2
View File
@@ -5,6 +5,32 @@
//! to libSQL through Toasty. It also hosts an immutable filesystem-backed WIT
//! package Registry. Actor memory remains process-local and is never presented
//! as durable state.
//!
//! # Ownership model
//!
//! * [`Console`] owns the in-process Runtime and serializes control-plane
//! mutations with a lifecycle mutex.
//! * libSQL stores desired service/deployment state and bounded telemetry.
//! * Component and WIT bytes are immutable filesystem artifacts addressed by
//! identity and digest.
//! * On restart, Actors are recreated from persisted manifests; their linear
//! memory is intentionally reset.
//!
//! # Example
//!
//! ```no_run
//! use wasmeld_console::{Console, ConsoleConfig};
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! let console = Console::new(ConsoleConfig::default()).await?;
//! for service in console.services()? {
//! println!("{}@{}: {:?}", service.id, service.revision, service.status);
//! }
//! # Ok(())
//! # }
//! ```
#![warn(missing_docs)]
mod api;
mod invocation_persistence;
@@ -82,6 +108,10 @@ impl Default for ConsoleConfig {
/// registered service metadata, artifacts, metrics, and events remain managed
/// by the Console.
pub struct Console {
// Lock order for lifecycle mutations is `lifecycle` -> `runtime` ->
// `state`. Invocation does not take `lifecycle`; it briefly reads Runtime
// and then updates state after the Actor call. Keep new code on this order
// to avoid deadlocks between HTTP control operations.
runtime: RwLock<Option<Runtime>>,
runtime_config: RuntimeConfig,
artifact_dir: PathBuf,
@@ -124,26 +154,37 @@ struct DeploymentRecord {
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ServiceStatus {
/// Actor is accepting invocations for this revision.
Running,
/// Revision is registered but has no active Actor.
Stopped,
/// Its most recent Actor trapped and must be started again.
Faulted,
}
/// API projection of one public service deployment.
#[derive(Clone, Debug, Serialize)]
pub struct DeploymentView {
/// Stable public service ID used by the Gateway route.
pub service_id: String,
/// Immutable revision currently selected for the service.
pub active_revision: String,
/// Current lifecycle status of the selected revision.
pub status: ServiceStatus,
/// Unix timestamp of the most recent deployment change.
pub updated_at_ms: u64,
}
/// Exact versioned host interface imported by a registered Component.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct CapabilityView {
/// Fully qualified versioned WIT interface.
pub interface: String,
/// WIT package identity without the version suffix.
pub package: String,
/// Interface name inside the package.
pub name: String,
/// Exact semantic version linked by the Runtime.
pub version: String,
}
@@ -161,16 +202,27 @@ impl From<CapabilityDescriptor> for CapabilityView {
/// API projection of a registered service and its current metrics.
#[derive(Clone, Debug, Serialize)]
pub struct ServiceView {
/// Stable service ID.
pub id: String,
/// Immutable Component revision.
pub revision: String,
/// Canonical path of the stored Component artifact.
pub artifact: String,
/// Fully versioned world declared by the package.
pub world: String,
/// Exact Host interfaces discovered from Component imports.
pub capabilities: Vec<CapabilityView>,
/// Current Actor status for this revision.
pub status: ServiceStatus,
/// Unix timestamp of the most recent lifecycle change.
pub updated_at_ms: u64,
/// Platform limits injected during registration.
pub limits: ResourceLimits,
/// Persisted number of completed invocation attempts.
pub calls: u64,
/// Persisted number of failed invocation attempts.
pub errors: u64,
/// Latency of the most recently persisted invocation.
pub last_latency_ms: Option<u64>,
}
@@ -195,11 +247,17 @@ impl ServiceRecord {
/// One persisted Console lifecycle or invocation event.
#[derive(Clone, Debug, Serialize)]
pub struct EventView {
/// Process-independent monotonically increasing event identity.
pub id: u64,
/// Event creation time as Unix milliseconds.
pub timestamp_ms: u64,
/// Stable category used by API consumers.
pub kind: EventKind,
/// Related service ID, or `None` for Runtime-wide events.
pub service_id: Option<String>,
/// Related immutable revision when the event targets one version.
pub revision: Option<String>,
/// Human-readable operational detail; not a machine protocol.
pub message: String,
}
@@ -207,12 +265,19 @@ pub struct EventView {
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EventKind {
/// A Component package was registered.
Registered,
/// An inactive revision and its artifacts were removed.
Unregistered,
/// A public deployment switched to a revision.
Deployed,
/// Runtime or Actor startup completed.
Started,
/// Runtime or Actor shutdown completed.
Stopped,
/// One data-plane or management invocation completed.
Invoked,
/// A lifecycle or invocation operation failed.
Failed,
}
@@ -348,55 +413,76 @@ struct GatewayInvocation {
/// Errors raised by Console state, persistence, Registry, or Runtime operations.
#[derive(Debug, Error)]
pub enum ConsoleError {
/// API input failed semantic validation.
#[error("{0}")]
InvalidRequest(String),
/// The requested registered service revision does not exist.
#[error("service revision {0} is not managed by Wasmeld")]
ServiceNotFound(ServiceKey),
/// The public service has no selected revision.
#[error("service {0} has no active deployment")]
DeploymentNotFound(String),
/// Unregister was attempted on the revision currently serving traffic.
#[error("service revision {0} is the active deployment")]
ActiveDeployment(ServiceKey),
/// An operation requiring Wasmtime was attempted while Runtime is stopped.
#[error("Wasmeld Runtime is not running")]
RuntimeNotRunning,
/// Uploaded package bytes exceed the configured control-plane limit.
#[error("artifact exceeds the {limit}-byte limit")]
ArtifactTooLarge { limit: usize },
ArtifactTooLarge {
/// Maximum accepted upload bytes.
limit: usize,
},
/// Component package decoding or integrity validation failed.
#[error(transparent)]
Package(#[from] PackageError),
/// WIT Registry storage or integrity validation failed.
#[error(transparent)]
WitRegistry(#[from] WitRegistryError),
/// Component artifact or manifest filesystem access failed.
#[error("failed to access {path}: {source}")]
Storage {
/// Path involved in the failed operation.
path: PathBuf,
/// Underlying filesystem failure.
#[source]
source: io::Error,
},
/// A generated Runtime manifest could not be encoded.
#[error("failed to serialize manifest: {0}")]
ManifestSerialize(#[from] toml::ser::Error),
/// Toasty or libSQL control-state persistence failed.
#[error("database operation failed: {0}")]
Database(#[from] toasty::Error),
/// The synchronous-to-async KV worker thread could not start.
#[error("failed to start persistent KV backend: {0}")]
KvBackendStart(#[source] io::Error),
/// Persisted rows contain an unknown enum, invalid key, or invalid revision.
#[error("database contains invalid console data: {0}")]
InvalidDatabaseData(String),
/// Component compilation, Actor lifecycle, or invocation failed.
#[error(transparent)]
Runtime(#[from] RuntimeError),
/// A blocking Runtime operation panicked or was cancelled.
#[error("blocking runtime task failed: {0}")]
Join(#[from] tokio::task::JoinError),
/// An internal lifecycle, state, or Runtime lock was poisoned.
#[error("console state lock was poisoned")]
LockPoisoned,
}
@@ -793,6 +879,11 @@ impl Console {
};
manifest.validate()?;
// Artifacts are committed before Runtime registration so
// `register_from_file` can recover the same bytes after restart.
// Every later fallible step removes files it created until the Runtime
// owns the registration. The HTTP wrapper persists the state snapshot
// after this method returns.
write_atomic(&component_path, &component_bytes)?;
let manifest_toml = toml::to_string_pretty(&manifest)?;
if let Err(error) = write_atomic(&manifest_path, manifest_toml.as_bytes()) {
@@ -950,6 +1041,10 @@ impl Console {
let result = runtime.invoke(key, input);
let latency_ms = duration_ms(started_at.elapsed());
// Update counters synchronously so management reads immediately
// observe this call. Durable telemetry is queued below and is
// intentionally best-effort: queue saturation or a process crash can
// lose recent counters/events. Do not use these values for billing.
let mut state = self.state()?;
let record = state
.services
@@ -995,6 +1090,9 @@ impl Console {
service_id: &str,
input: Vec<u8>,
) -> Result<GatewayInvocation, ConsoleError> {
// Clone the selected revision while holding state, then release the
// lock before entering Runtime. An activation racing this call affects
// only subsequent resolutions; this call remains pinned to `revision`.
let revision = {
let state = self.state()?;
state
@@ -1139,7 +1237,9 @@ impl Console {
async fn persist_snapshot(&self) -> Result<(), ConsoleError> {
// Serialize snapshots so a slower earlier write cannot overwrite a
// newer state captured by a concurrent HTTP operation.
// newer state captured by a concurrent HTTP operation. This mutex is
// also shared with the invocation delta writer, which prevents the two
// transaction styles from interleaving.
let _sync = self.persistence_sync.lock().await;
let (services, events, deployments) = self.persistence_snapshot()?;
self.persistence.sync(services, events, deployments).await?;
+26
View File
@@ -1,4 +1,25 @@
//! Standalone Wasmeld control-plane and data-plane server entry point.
//!
//! Configuration is supplied through environment variables:
//!
//! - `WASMELD_ADDR` binds the management API (default `127.0.0.1:8080`).
//! - `WASMELD_GATEWAY_ADDR` binds the public byte API (default `0.0.0.0:8081`).
//! - `WASMELD_ARTIFACT_DIR`, `WASMELD_WIT_REGISTRY_DIR`, and
//! `WASMELD_DATABASE_PATH` select durable local storage.
//! - `WASMELD_ALLOWED_ORIGINS` is a comma-separated management UI CORS list.
//! - `RUST_LOG` configures tracing.
//!
//! For example:
//!
//! ```text
//! WASMELD_ADDR=127.0.0.1:8080 \
//! WASMELD_GATEWAY_ADDR=0.0.0.0:8081 \
//! RUST_LOG=wasmeld_console=debug \
//! cargo run -p wasmeld-console
//! ```
//!
//! The defaults expose only the gateway on non-loopback interfaces.
//! Authentication and TLS must be supplied by the deployment environment.
use std::{env, net::SocketAddr, path::PathBuf, sync::Arc};
@@ -45,6 +66,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let gateway = gateway_app(Arc::clone(&console));
let management_listener = tokio::net::TcpListener::bind(management_address).await?;
let gateway_listener = tokio::net::TcpListener::bind(gateway_address).await?;
// Both servers share one shutdown edge so neither listener remains alive
// after Ctrl+C has begun process termination.
let (shutdown_sender, shutdown_receiver) = tokio::sync::watch::channel(false);
let signal_task = tokio::spawn(async move {
shutdown_signal().await;
@@ -61,6 +84,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
);
signal_task.abort();
result?;
// Axum has stopped accepting requests at this point. Flush accepted
// telemetry before dropping Console; persistence failures are traced by
// the worker because telemetry is an operational, best-effort stream.
console.flush_invocation_telemetry().await;
Ok(())
}
+31
View File
@@ -3,6 +3,15 @@
//! Component bytes and WIT packages remain filesystem artifacts. This module
//! persists service manifests, deployments, metrics, the bounded event
//! history, and service-scoped Host KV entries.
//!
//! There are two write paths:
//!
//! - [`Persistence::sync`] stores a complete control-plane snapshot.
//! - [`Persistence::record_invocations`] applies ordered metric deltas.
//!
//! Callers serialize these paths with the Console persistence mutex. Existing
//! service counters are intentionally owned by the delta path; a snapshot
//! updates their manifest and timestamp without replacing those counters.
use std::{
collections::BTreeSet,
@@ -55,6 +64,10 @@ pub(crate) struct StoredKvEntry {
}
/// One invocation's durable metric delta and event.
///
/// The caller assigns the event ID before enqueueing the update. A missing
/// service row is tolerated because unregister may race queued telemetry; the
/// event is still retained for diagnostics.
pub(crate) struct InvocationUpdate {
pub service_key: String,
pub failed: bool,
@@ -64,6 +77,10 @@ pub(crate) struct InvocationUpdate {
}
/// Serialized access to the Toasty database connection.
///
/// This mutex protects a single Toasty [`Db`] handle. It is separate from the
/// higher-level Console persistence mutex, which coordinates whole snapshot
/// and invocation-delta operations before they acquire this connection.
#[derive(Clone)]
pub(crate) struct Persistence {
db: Arc<Mutex<Db>>,
@@ -131,6 +148,11 @@ impl Persistence {
}
/// Atomically upserts a Console snapshot and prunes expired events.
///
/// The input is authoritative for services and deployments: rows omitted
/// from those collections are removed. Events older than the oldest
/// supplied retained event are pruned. Existing invocation counters are
/// preserved because [`Self::record_invocations`] owns counter changes.
pub async fn sync(
&self,
services: Vec<StoredService>,
@@ -178,6 +200,8 @@ impl Persistence {
}
}
// Console stores events newest-first, so the last element is the
// oldest ID that should survive snapshot pruning.
if let Some(oldest_event) = events.last() {
StoredEvent::filter(StoredEvent::fields().id().lt(oldest_event.id))
.delete()
@@ -217,6 +241,9 @@ impl Persistence {
}
/// Atomically records invocation deltas and their bounded event history.
///
/// All supplied updates commit together. Metrics use saturating arithmetic
/// so corrupted or extremely old databases cannot wrap counters.
pub(crate) async fn record_invocations(
&self,
updates: Vec<InvocationUpdate>,
@@ -268,6 +295,8 @@ impl Persistence {
service_id: &str,
entry_key: &str,
) -> toasty::Result<Option<Vec<u8>>> {
// The namespace is the stable service ID rather than its revision.
// Deploying a new revision therefore preserves application state.
let mut db = self.db.lock().await;
Ok(
StoredKvEntry::filter_by_service_id_and_entry_key(service_id, entry_key)
@@ -284,6 +313,8 @@ impl Persistence {
entry_key: String,
value: Vec<u8>,
) -> toasty::Result<()> {
// Upsert makes a retry after an ambiguous Host timeout idempotent for
// the same `(service_id, entry_key, value)`.
let mut db = self.db.lock().await;
StoredKvEntry::upsert_by_service_id_and_entry_key(service_id, entry_key)
.value(value)
+13 -1
View File
@@ -37,28 +37,40 @@ struct StoredWitPackage {
/// Errors produced by WIT Registry storage and integrity checks.
#[derive(Debug, Error)]
pub enum WitRegistryError {
/// Uploaded binary WIT exceeds the configured byte limit.
#[error("WIT package exceeds the {limit}-byte limit")]
TooLarge { limit: usize },
TooLarge {
/// Maximum accepted artifact bytes.
limit: usize,
},
/// The immutable `name@version` already exists.
#[error("WIT package {0} is already published and cannot be overwritten")]
AlreadyExists(String),
/// No indexed artifact matches the requested identity.
#[error("WIT package {0} was not found")]
NotFound(String),
/// Uploaded or persisted bytes are not a valid versioned binary WIT package.
#[error(transparent)]
InvalidPackage(#[from] WitPackageError),
/// Directory layout and decoded artifact identity disagree.
#[error("invalid WIT registry data: {0}")]
InvalidStorage(String),
/// Registry directory or artifact filesystem access failed.
#[error("failed to access {path}: {source}")]
Storage {
/// Path involved in the failed operation.
path: PathBuf,
/// Underlying filesystem failure.
#[source]
source: io::Error,
},
/// The process-local Registry index lock was poisoned.
#[error("WIT registry lock was poisoned")]
LockPoisoned,
}
+37
View File
@@ -1,4 +1,22 @@
//! Local build, watch, and deployment loop for Component development.
//!
//! The loop fingerprints Rust/WIT inputs, materializes WIT dependencies,
//! compiles a Component, creates a content-addressed development revision,
//! registers it, and atomically switches the Console deployment. A failed
//! build or deployment never removes the previously active revision.
//!
//! Build and deployment are separate states. Once a build succeeds,
//! `pending_deployment` retains that exact package and retries network or
//! Console failures without rebuilding it. A later source change supersedes
//! the pending package and starts a new build.
//!
//! # Example
//!
//! ```text
//! cargo run -p wasmeld-package --bin wasmeld -- \
//! dev components/counter/Cargo.toml \
//! --console http://127.0.0.1:8080
//! ```
use std::{
collections::BTreeSet,
@@ -57,6 +75,9 @@ pub(crate) fn run(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Erro
fs::create_dir_all(&temporary_dir)?;
let output = temporary_dir.join("component.wasmpkg");
let mut observed = None;
// Keeping this separate from `observed` lets transient deployment errors
// retry the already-built bytes. Rebuilding on every HTTP failure would
// waste time and could create a different revision unexpectedly.
let mut pending_deployment = None;
println!("watching:");
@@ -138,6 +159,10 @@ fn deploy_component(
console: &str,
packed: &PackedComponent,
) -> Result<(), Box<dyn std::error::Error>> {
// Order is intentional: register makes the immutable revision available,
// activate switches new gateway resolutions, and only then may old
// development revisions be removed. Cleanup is best-effort because a
// successful deployment must not be reported as failed due to stale files.
register(client, console, packed)?;
activate(
client,
@@ -235,6 +260,9 @@ fn register(
if response.status().is_success() {
return Ok(());
}
// Retrying a package whose registration response was lost is safe only
// when the exact immutable identity already exists. Do not treat every
// conflict as success; it may represent a different control-plane error.
if response.status() == StatusCode::CONFLICT
&& service_exists(
client,
@@ -334,6 +362,9 @@ fn watch_roots(manifest_path: &Path) -> Result<Vec<PathBuf>, Box<dyn std::error:
let module_path = component_root.join(MODULE_MANIFEST_FILE);
if module_path.is_file() {
let module = ModuleManifest::read(&module_path)?;
// Path replacements are source inputs just like the Component itself.
// Registry dependencies are immutable and represented by wit.lock, so
// they do not need independent watch roots.
for replacement in module.replacements.values() {
let path = component_root.join(&replacement.path);
if path.exists() {
@@ -351,6 +382,8 @@ fn source_fingerprint(roots: &[PathBuf]) -> Result<Vec<u8>, Box<dyn std::error::
}
let mut digest = Sha256::new();
for path in files {
// Include paths as well as bytes so file renames trigger a rebuild even
// when their contents are unchanged.
digest.update(path.to_string_lossy().as_bytes());
match fs::read(&path) {
Ok(bytes) => digest.update(bytes),
@@ -384,6 +417,8 @@ fn ignored_directory(path: &Path) -> bool {
name,
Some(".git" | ".wasmeld" | "deps" | "dist" | "node_modules" | "target")
) {
// `wit/deps` is generated by dependency sync. Watching it would make
// each build rewrite watched files and trigger an endless rebuild.
return name != Some("deps")
|| path
.parent()
@@ -395,6 +430,8 @@ fn ignored_directory(path: &Path) -> bool {
}
fn is_source_file(path: &Path) -> bool {
// The list is deliberately narrow: fingerprints should represent inputs
// to Cargo/WIT resolution, not editor state or generated artifacts.
matches!(
path.extension().and_then(|extension| extension.to_str()),
Some("rs" | "wit")
+67 -2
View File
@@ -5,6 +5,34 @@
//! containing exactly [`PACKAGE_MANIFEST_PATH`] and [`COMPONENT_PATH`]. WIT
//! packages use the Component Model binary WIT encoding implemented by the
//! [`wit_package`] module; they are not stored in `.wasmpkg` containers.
//!
//! # Example
//!
//! ```
//! use std::io::Cursor;
//! use wasmeld_package::{read_package, write_package};
//!
//! let component = b"\0asm\x0d\0\x01\0";
//! let mut archive = Cursor::new(Vec::new());
//! write_package(
//! &mut archive,
//! "image-resize",
//! "1.0.0",
//! "example:image-resize/service@1.0.0",
//! component,
//! )?;
//!
//! let decoded = read_package(archive.get_ref(), 1024 * 1024)?;
//! assert_eq!(decoded.manifest.id, "image-resize");
//! assert_eq!(decoded.component, component);
//! # Ok::<(), wasmeld_package::PackageError>(())
//! ```
//!
//! The decoder treats an uploaded archive as untrusted input: it accepts only
//! the two canonical entries, limits uncompressed sizes, validates identifiers,
//! and verifies the Component digest before returning bytes to the Runtime.
#![warn(missing_docs)]
use std::{
collections::BTreeSet,
@@ -35,16 +63,26 @@ const MAX_MANIFEST_BYTES: usize = 64 * 1024;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PackageManifest {
/// Manifest schema understood by this version of Wasmeld.
pub schema_version: u32,
/// Stable service identifier used in management and gateway routes.
pub id: String,
/// Immutable service revision; the same `id@revision` cannot be overwritten.
pub revision: String,
/// Fully versioned WIT world implemented by the Component.
pub world: String,
/// Archive path of the executable Component; currently always [`COMPONENT_PATH`].
pub component: String,
/// Lowercase SHA-256 digest of the uncompressed Component bytes.
pub sha256: String,
}
impl PackageManifest {
/// Creates a manifest and computes the component digest.
///
/// This constructor does not reject invalid identifiers or worlds. Call
/// [`Self::validate`] before persisting it; [`write_package`] does this
/// automatically.
pub fn new(
id: impl Into<String>,
revision: impl Into<String>,
@@ -94,31 +132,48 @@ impl PackageManifest {
/// A validated component package decoded from a `.wasmpkg` archive.
#[derive(Debug)]
pub struct ComponentPackage {
/// Validated identity, world, and integrity metadata.
pub manifest: PackageManifest,
/// Uncompressed Component bytes whose digest matches the manifest.
pub component: Vec<u8>,
}
/// Errors produced while creating or validating component packages.
#[derive(Debug, Error)]
pub enum PackageError {
/// The archive layout or a manifest value violates the package contract.
#[error("invalid component package: {0}")]
InvalidPackage(String),
/// The uncompressed Component exceeds the caller-selected upload limit.
#[error("component exceeds the {limit}-byte uncompressed limit")]
ComponentTooLarge { limit: usize },
ComponentTooLarge {
/// Maximum number of accepted uncompressed bytes.
limit: usize,
},
/// Component bytes do not match the immutable digest in `package.toml`.
#[error("component digest mismatch: expected {expected}, found {actual}")]
DigestMismatch { expected: String, actual: String },
DigestMismatch {
/// Digest declared by the package manifest.
expected: String,
/// Digest computed from the uploaded Component.
actual: String,
},
/// Reading or writing the package stream failed.
#[error("failed to read or write package: {0}")]
Io(#[from] io::Error),
/// The bytes are not a valid constrained ZIP archive.
#[error("invalid ZIP container: {0}")]
Zip(#[from] ZipError),
/// `package.toml` could not be decoded.
#[error("invalid package manifest: {0}")]
ManifestParse(#[from] toml::de::Error),
/// A generated package manifest could not be encoded.
#[error("failed to serialize package manifest: {0}")]
ManifestSerialize(#[from] toml::ser::Error),
}
@@ -127,6 +182,11 @@ pub enum PackageError {
///
/// The caller owns the destination writer. Runtime limits are intentionally not
/// included because the Console injects platform policy during registration.
///
/// # Errors
///
/// Returns an error when the identity or world is invalid, the Component is
/// empty, or the destination cannot be written.
pub fn write_package<W>(
writer: W,
id: impl Into<String>,
@@ -162,6 +222,11 @@ where
///
/// `max_component_bytes` applies to the uncompressed component so compressed
/// archives cannot bypass the platform's artifact limit.
///
/// # Errors
///
/// Returns an error for malformed ZIP data, extra or duplicate entries,
/// oversized content, invalid metadata, empty Components, or digest mismatch.
pub fn read_package(
package_bytes: &[u8],
max_component_bytes: usize,
+41
View File
@@ -1,4 +1,18 @@
//! Command-line entry point for Component packaging and WIT dependency workflows.
//!
//! Typical release packaging:
//!
//! ```text
//! wasmeld wit fetch --manifest components/counter/wasmeld.toml --locked
//! wasmeld pack components/counter/Cargo.toml --locked
//! ```
//!
//! `wit fetch` resolves registry or local `replace` sources into `wit/deps`
//! and writes `wit.lock`. `--locked` verifies that resolution matches the
//! existing lock file and is the appropriate mode for CI. A `replace` changes
//! only the development source used for a declared package identity; it does
//! not rename that package or make the local path part of the published
//! Component contract.
mod dev;
@@ -21,7 +35,9 @@ const TARGET: &str = "wasm32-wasip2";
#[derive(Clone, Copy)]
pub(crate) enum BuildProfile {
/// Fast local build with Cargo's `debug` profile.
Debug,
/// Optimized artifact from Cargo's `release` profile.
Release,
}
@@ -35,22 +51,36 @@ impl BuildProfile {
}
pub(crate) enum PackageIdentity {
/// Use `[package.metadata.wasmeld].id` and the Cargo package version.
Cargo,
/// Use a development service ID and a Component-content revision suffix.
Development { id: Option<String> },
}
pub(crate) struct PackRequest {
/// Cargo manifest for the Component crate.
pub manifest_path: PathBuf,
/// Destination package, or the workspace `dist` default.
pub output: Option<PathBuf>,
/// Reuse an artifact already present under Cargo's target directory.
///
/// The caller is responsible for ensuring it matches current sources and
/// the requested profile.
pub no_build: bool,
/// Require `wit.lock` to match dependency resolution exactly.
pub locked: bool,
/// Cargo profile used to build and locate the Component.
pub profile: BuildProfile,
/// Stable release or content-addressed development identity.
pub identity: PackageIdentity,
}
pub(crate) struct PackedComponent {
/// Validated metadata embedded in the `.wasmpkg`.
pub manifest: PackageManifest,
/// Path to the resulting `.wasmpkg`.
pub output: PathBuf,
/// Path to the raw Component produced or reused by Cargo.
pub artifact: PathBuf,
}
@@ -141,6 +171,9 @@ pub(crate) fn pack_component(
request: PackRequest,
) -> Result<PackedComponent, Box<dyn std::error::Error>> {
let manifest_path = fs::canonicalize(request.manifest_path)?;
// Materialize WIT before asking Cargo for metadata or compiling: the
// component's `wit_bindgen::generate!` reads `wit/world.wit` and
// `wit/deps` during macro expansion.
sync_component_dependencies(&manifest_path, request.locked)?;
let metadata = cargo_metadata(&manifest_path)?;
let package = metadata
@@ -189,6 +222,9 @@ pub(crate) fn pack_component(
let (id, revision) = match request.identity {
PackageIdentity::Cargo => (cargo_id, cargo_revision),
PackageIdentity::Development { id } => {
// The revision changes only when compiled Component bytes change.
// This makes registration retryable and avoids mutating an
// immutable identity while editing.
let digest = format!("{:x}", Sha256::digest(&component));
(
id.unwrap_or_else(|| format!("{cargo_id}-dev")),
@@ -316,6 +352,8 @@ fn run_wit_replace(
let manifest_path = resolve_module_manifest(manifest_path)?;
let mut manifest = ModuleManifest::read(&manifest_path)?;
// Replace is recorded in the source manifest. The next fetch/pack resolves
// and locks its contents; generated `wit/deps` must not be hand-edited.
if drop {
if !manifest.drop_replacement(&package) {
return Err(format!("replacement {package:?} does not exist").into());
@@ -407,6 +445,9 @@ fn run_wit_publish(
return Err(format!("registry returned HTTP {status}: {}", response.text()?).into());
}
let published = response.json::<WitPackageMetadata>()?;
// A successful status is insufficient: compare the server's canonical
// identity and digest to detect a proxy or Registry returning metadata for
// different bytes.
if published != package.metadata {
return Err(format!(
"registry metadata mismatch: expected {}@{} ({})",
+78 -5
View File
@@ -4,6 +4,23 @@
//! prefers root-level path replacements and otherwise downloads immutable
//! binary packages from the configured Registry. The complete transitive graph
//! is materialized under `wit/deps` and recorded in `wit.lock`.
//!
//! `wasmeld.toml` is the human-authored input; `wit.lock` is the reproducibility
//! record; and `wit/deps` is disposable generated output. Commit the first two,
//! but regenerate `wit/deps` with [`crate::module::sync_dependencies`] after
//! cloning.
//!
//! # Example
//!
//! ```no_run
//! use wasmeld_package::module::sync_dependencies;
//!
//! // CI uses locked mode so changed Registry content, replacements, or
//! // transitive dependencies fail instead of rewriting wit.lock.
//! let report = sync_dependencies("components/image-resize/wasmeld.toml", true)?;
//! println!("resolved {} packages", report.packages.len());
//! # Ok::<(), wasmeld_package::module::ModuleError>(())
//! ```
use std::{
collections::{BTreeMap, BTreeSet},
@@ -156,39 +173,79 @@ pub struct SyncReport {
/// Errors raised while parsing, resolving, locking, or materializing WIT dependencies.
#[derive(Debug, Error)]
pub enum ModuleError {
/// A manifest value, dependency identity, version, or path is invalid.
#[error("invalid WIT module: {0}")]
InvalidManifest(String),
/// A manifest, lock file, or local WIT source could not be read.
#[error("failed to read {path}: {source}")]
Read { path: PathBuf, source: io::Error },
Read {
/// File or directory involved in the failed read.
path: PathBuf,
/// Underlying filesystem error.
source: io::Error,
},
/// A lock file or materialized dependency could not be written.
#[error("failed to write {path}: {source}")]
Write { path: PathBuf, source: io::Error },
Write {
/// Destination that could not be updated.
path: PathBuf,
/// Underlying filesystem error.
source: io::Error,
},
/// TOML input could not be decoded.
#[error("failed to parse {path}: {source}")]
ParseManifest {
/// Manifest or lock file containing invalid TOML.
path: PathBuf,
/// TOML decoder error.
source: toml::de::Error,
},
/// A canonical manifest or lock file could not be encoded as TOML.
#[error("failed to serialize {path}: {source}")]
SerializeManifest {
/// Destination whose model could not be serialized.
path: PathBuf,
/// TOML encoder error.
source: toml::ser::Error,
},
/// Local or downloaded bytes are not the requested versioned WIT package.
#[error("invalid WIT package at {path}: {message}")]
InvalidWitPackage { path: PathBuf, message: String },
InvalidWitPackage {
/// Source path used while validating the package.
path: PathBuf,
/// Identity, version, parsing, or dependency validation detail.
message: String,
},
/// Locked resolution produced a graph different from `wit.lock`.
#[error("locked dependency graph differs from {path}; run `wasmeld wit tidy`")]
LockMismatch { path: PathBuf },
LockMismatch {
/// Existing lock file that must be updated outside locked mode.
path: PathBuf,
},
/// Fetching or validating an immutable Registry artifact failed.
#[error("WIT registry request to {url} failed: {message}")]
Registry { url: String, message: String },
Registry {
/// Exact Registry endpoint requested by the resolver.
url: String,
/// HTTP, size-limit, digest, or package-validation detail.
message: String,
},
}
impl ModuleManifest {
/// Reads and validates a `wasmeld.toml` manifest.
///
/// # Errors
///
/// Returns [`ModuleError::Read`], [`ModuleError::ParseManifest`], or
/// [`ModuleError::InvalidManifest`] without modifying the source file.
pub fn read(path: impl AsRef<Path>) -> Result<Self, ModuleError> {
let path = path.as_ref();
let source = fs::read_to_string(path).map_err(|source| ModuleError::Read {
@@ -205,6 +262,9 @@ impl ModuleManifest {
}
/// Validates and writes a canonical TOML manifest.
///
/// Callers editing replacements should mutate a parsed value and invoke
/// this method once, so invalid intermediate state is never persisted.
pub fn write(&self, path: impl AsRef<Path>) -> Result<(), ModuleError> {
self.validate()?;
let path = path.as_ref();
@@ -248,6 +308,9 @@ impl ModuleManifest {
}
/// Sets a package-wide or exact-version path replacement.
///
/// An exact key such as `wasmeld:kv@0.1.0` takes precedence over the
/// package-wide `wasmeld:kv` key during resolution.
pub fn set_path_replacement(
&mut self,
package: impl Into<String>,
@@ -311,6 +374,16 @@ impl ModuleLock {
/// dependencies embedded in Registry artifacts. With `locked = true`, the
/// computed graph must exactly match the existing `wit.lock`; the function does
/// not update the lock file.
///
/// The dependency directory is assembled in a temporary sibling directory and
/// swapped into place only after the complete graph validates. A failed fetch
/// therefore leaves both the lock and materialized dependencies intact.
///
/// # Errors
///
/// Returns an error when the manifest or lock is invalid, a replacement has the
/// wrong identity, a Registry package is unavailable or oversized, or two paths
/// resolve the same package version to different content.
pub fn sync_dependencies(
manifest_path: impl AsRef<Path>,
locked: bool,
+29 -1
View File
@@ -21,7 +21,9 @@ pub const WIT_PACKAGE_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WitDependency {
/// Package identity in `namespace:name` form.
pub name: String,
/// Exact semantic version encoded by the dependency package.
pub version: String,
}
@@ -29,32 +31,48 @@ pub struct WitDependency {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WitPackageMetadata {
/// Metadata response schema used by the Registry API.
pub schema_version: u32,
/// Package identity derived from binary WIT contents.
pub name: String,
/// Explicit semantic version derived from binary WIT contents.
pub version: String,
/// SHA-256 digest of the complete binary WIT artifact.
pub sha256: String,
/// Exact direct dependencies embedded in the package.
pub dependencies: Vec<WitDependency>,
}
/// Encoded package bytes together with metadata derived from those bytes.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BuiltWitPackage {
/// Metadata derived by decoding the bytes after encoding.
pub metadata: WitPackageMetadata,
/// Standard Component Model binary WIT package.
pub bytes: Vec<u8>,
}
/// Errors raised while parsing WIT source or decoding a binary WIT package.
#[derive(Debug, Error)]
pub enum WitPackageError {
/// WIT source could not be parsed or resolved.
#[error("failed to parse WIT package at {path}: {message}")]
Source { path: PathBuf, message: String },
Source {
/// Source file or directory passed to the parser.
path: PathBuf,
/// Parser or dependency-resolution detail.
message: String,
},
/// A resolved WIT package could not be encoded.
#[error("failed to encode WIT package: {0}")]
Encode(String),
/// Input bytes are not a standard binary WIT package.
#[error("invalid binary WIT package: {0}")]
Decode(String),
/// The package identity omits the version required by the Registry.
#[error("WIT package {0} must declare an explicit semantic version")]
MissingVersion(String),
}
@@ -64,6 +82,11 @@ pub enum WitPackageError {
/// The package must declare an explicit semantic version. Dependencies
/// available through the source path are encoded as Component Model package
/// references and reported in [`WitPackageMetadata::dependencies`].
///
/// # Errors
///
/// Returns an error when WIT parsing, dependency resolution, binary encoding,
/// or post-encode inspection fails.
pub fn build_wit_package(path: impl AsRef<Path>) -> Result<BuiltWitPackage, WitPackageError> {
let path = path.as_ref();
let mut resolve = Resolve::default();
@@ -83,6 +106,11 @@ pub fn build_wit_package(path: impl AsRef<Path>) -> Result<BuiltWitPackage, WitP
///
/// Ordinary WebAssembly Components are rejected even though both formats use a
/// Component Model binary container.
///
/// # Errors
///
/// Returns an error if the bytes are malformed, contain a Component instead of
/// a WIT package, or any package in the direct dependency set lacks a version.
pub fn inspect_wit_package(bytes: &[u8]) -> Result<WitPackageMetadata, WitPackageError> {
let decoded = decode(bytes).map_err(|error| WitPackageError::Decode(error.to_string()))?;
let DecodedWasm::WitPackage(resolve, package) = decoded else {
@@ -49,10 +49,13 @@ pub trait KvBackend: fmt::Debug + Send + Sync {
/// Storage failures returned by a [`KvBackend`].
#[derive(Debug, Error)]
pub enum KvBackendError {
/// Backend queue or connection pool is temporarily saturated.
#[error("KV backend is busy")]
Busy,
/// Backend did not finish within the Host-call timeout.
#[error("KV backend timed out")]
Timeout,
/// Backend rejected or failed the operation for another reason.
#[error("KV backend operation failed: {0}")]
Operation(String),
}
+65 -5
View File
@@ -9,98 +9,158 @@ use crate::manifest::ServiceKey;
/// Failures from Runtime configuration, sandbox validation, or Actor execution.
#[derive(Debug, Error)]
pub enum RuntimeError {
/// Service ID or revision is empty.
#[error("service id and revision must not be empty")]
InvalidServiceKey,
/// Manifest identity, world, path, or resource limits are invalid.
#[error("invalid manifest: {0}")]
InvalidManifest(String),
/// Runtime manifest TOML could not be decoded.
#[error("manifest parse failed: {0}")]
ManifestParse(#[from] toml::de::Error),
/// A lifecycle operation addressed an unknown service revision.
#[error("service revision {0} is not registered")]
ServiceNotRegistered(ServiceKey),
/// Registration attempted to overwrite an immutable service revision.
#[error("service revision {0} is already registered")]
ServiceAlreadyRegistered(ServiceKey),
/// Wasmtime rejected the Component before it entered the registry.
#[error("component compilation failed: {0}")]
ComponentCompilation(#[source] wasmtime::Error),
/// The Component imports a WASI or Wasmeld interface outside the allowlist.
#[error("component imports unsupported capability {0}")]
UnsupportedImport(String),
/// A supported capability has no configured Host backend.
#[error("component requires unavailable capability {0}")]
CapabilityUnavailable(String),
/// The shared Wasmtime Engine could not be created.
#[error("runtime creation failed: {0}")]
RuntimeCreation(#[source] wasmtime::Error),
/// The configured compiled-Component cache directory could not be prepared.
#[error("failed to prepare Component compilation cache {path}: {source}")]
CacheDirectory {
/// Requested cache directory.
path: PathBuf,
/// Underlying filesystem failure.
#[source]
source: std::io::Error,
},
/// Wasmtime rejected the persistent cache configuration.
#[error("invalid Component compilation cache configuration: {0}")]
CacheConfiguration(#[source] wasmtime::Error),
/// A Component path from a manifest could not be read.
#[error("failed to read component artifact {path}: {source}")]
ArtifactRead {
/// Manifest-provided artifact path.
path: String,
/// Underlying filesystem failure.
#[source]
source: std::io::Error,
},
/// The operating system refused to create an Actor worker thread.
#[error("failed to spawn actor thread: {0}")]
ActorThread(#[source] std::io::Error),
/// Linking, instantiation, or the Component `init` export failed.
#[error("actor initialization failed: {0}")]
ActorInitialization(String),
/// The Actor is stopped, faulted, starting, or no longer accepting work.
#[error("actor for {0} is unavailable")]
ActorUnavailable(ServiceKey),
/// The bounded Actor mailbox has no capacity for another command.
#[error("actor mailbox for {0} is full")]
ActorOverloaded(ServiceKey),
/// The worker exited before returning a queued response.
#[error("actor for {0} stopped before returning a result")]
ActorStopped(ServiceKey),
/// Resident dispatch was attempted on a passive service Component.
#[error("service revision {0} does not export the resident actor interface")]
NotResident(ServiceKey),
/// A Host event payload exceeds its configured resident or input limit.
#[error("resident event for {service} exceeds the {limit}-byte payload limit")]
ResidentEventTooLarge { service: ServiceKey, limit: usize },
ResidentEventTooLarge {
/// Actor revision receiving the event.
service: ServiceKey,
/// Maximum accepted bytes.
limit: usize,
},
/// A Component returned more effects than one event is allowed to produce.
#[error("resident event for {service} returned more than {limit} effects")]
TooManyResidentEffects { service: ServiceKey, limit: usize },
TooManyResidentEffects {
/// Actor revision that returned the effect list.
service: ServiceKey,
/// Maximum effects accepted per event.
limit: usize,
},
/// A raw Component effect has invalid IDs, payloads, names, or timer values.
#[error("invalid resident effect: {0}")]
InvalidResidentEffect(String),
/// Invocation input exceeds the service manifest limit.
#[error("input for {service} exceeds the {limit}-byte limit")]
InputTooLarge { service: ServiceKey, limit: usize },
InputTooLarge {
/// Actor revision receiving the input.
service: ServiceKey,
/// Maximum accepted bytes.
limit: usize,
},
/// Invocation output exceeds the service manifest limit.
#[error("output for {service} exceeds the {limit}-byte limit")]
OutputTooLarge { service: ServiceKey, limit: usize },
OutputTooLarge {
/// Actor revision returning the output.
service: ServiceKey,
/// Maximum accepted bytes.
limit: usize,
},
/// Queue waiting and Component execution exceeded the end-to-end deadline.
#[error("actor for {service} exceeded its {deadline:?} execution deadline")]
DeadlineExceeded {
/// Actor revision whose command expired.
service: ServiceKey,
/// Configured end-to-end command deadline.
deadline: Duration,
},
/// A Wasm trap made the current Actor instance unusable.
#[error("actor for {service} faulted: {message}")]
ActorFault {
/// Faulted Actor revision.
service: ServiceKey,
/// Wasmtime trap or call failure detail.
message: String,
},
/// Guest code returned a typed WIT error without trapping the Actor.
#[error("component returned {kind}: {message}")]
ComponentError { kind: &'static str, message: String },
ComponentError {
/// Export phase such as `init`, `invoke`, or `resident-event`.
kind: &'static str,
/// Guest-provided diagnostic text.
message: String,
},
/// An internal registry or lifecycle mutex was poisoned by a panic.
#[error("runtime lock was poisoned")]
LockPoisoned,
}
+28
View File
@@ -4,6 +4,34 @@
//! It provides a restricted WASI compatibility context, links explicitly
//! imported host capabilities, loads a Component that implements the stable
//! service exports, and invokes it through one serial Actor.
//!
//! # Request/response example
//!
//! ```no_run
//! use wasmeld_runtime::{ResourceLimits, Runtime, RuntimeConfig, ServiceManifest};
//!
//! let runtime = Runtime::new(RuntimeConfig::default())?;
//! let manifest = ServiceManifest {
//! id: "echo".into(),
//! revision: "1.0.0".into(),
//! component: "echo.wasm".into(),
//! world: "component:echo/echo-component@1.0.0".into(),
//! limits: ResourceLimits::default(),
//! };
//! let bytes = std::fs::read(&manifest.component)?;
//! let key = runtime.register(manifest, bytes)?;
//! let actor = runtime.start(&key, Vec::new())?;
//! assert_eq!(actor.invoke(b"hello".to_vec())?, b"hello");
//! runtime.stop(&key)?;
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
//!
//! A started Actor retains Component memory between calls, but Component code
//! is never allowed to monopolize the thread between calls. Active timers,
//! sockets, subscriptions, and other long-lived resources are Host-owned and
//! enter resident Components as bounded events through [`ResidentSession`].
#![warn(missing_docs)]
mod bindings;
mod capability;
+69
View File
@@ -5,6 +5,11 @@
//! The Component never receives an operating-system descriptor. It can only
//! reference opaque [`ResourceId`] values and return [`ResidentEffect`] values
//! for the owning driver to validate and apply.
//!
//! Driver authors normally use [`ResidentSession`](crate::ResidentSession)
//! instead of constructing events directly. The session proves that every ID
//! belongs to the current service revision and converts raw effects into
//! validated [`ResidentOperation`](crate::ResidentOperation) values.
use serde::{Deserialize, Serialize};
@@ -46,10 +51,15 @@ impl ResourceId {
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StreamCloseReason {
/// The remote peer completed or reset the connection.
PeerClosed,
/// The platform or Component requested an orderly close.
HostClosed,
/// The Host driver enforced its configured inactivity timeout.
IdleTimeout,
/// A protocol driver rejected malformed or out-of-order bytes.
ProtocolError,
/// The operating system or transport driver reported an I/O failure.
TransportError,
}
@@ -57,44 +67,77 @@ pub enum StreamCloseReason {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ResidentEvent {
/// A registered Host timer reached its deadline.
Timer {
/// Timer resource previously registered with the session.
timer_id: ResourceId,
/// Intended firing instant on the Host monotonic clock, in nanoseconds.
///
/// This is not wall-clock time and may be earlier than delivery when
/// the Actor mailbox is busy.
scheduled_at_ns: u64,
},
/// A TCP or Unix listener accepted a new byte stream.
StreamOpened {
/// Listener resource that accepted the connection.
endpoint_id: ResourceId,
/// Newly allocated stream resource scoped to this Actor revision.
stream_id: ResourceId,
/// Driver-formatted peer address, when the transport provides one.
peer: Option<String>,
},
/// A bounded chunk was read from an open stream.
StreamData {
/// Stream that produced the bytes.
stream_id: ResourceId,
/// Raw transport bytes; framing remains the Component or protocol driver's job.
bytes: Vec<u8>,
},
/// Backpressure cleared and the stream can accept another write.
StreamWritable {
/// Stream whose driver write buffer has capacity.
stream_id: ResourceId,
},
/// The peer sent EOF but the local write half remains available.
StreamHalfClosed {
/// Stream entering the peer-half-closed state.
stream_id: ResourceId,
},
/// A stream reached terminal state and will not produce more events.
StreamClosed {
/// Stream being released after this callback.
stream_id: ResourceId,
/// Terminal reason reported by the Host driver.
reason: StreamCloseReason,
},
/// One UDP packet arrived on a registered datagram endpoint.
Datagram {
/// UDP endpoint that received the packet.
endpoint_id: ResourceId,
/// Driver-formatted source address used for a possible reply.
peer: String,
/// Complete datagram payload; datagram boundaries are preserved.
bytes: Vec<u8>,
},
/// A broker or internal queue delivered one message.
Message {
/// Host-owned subscription that delivered the message.
subscription_id: ResourceId,
/// Driver-scoped delivery identity used when acknowledging.
message_id: u64,
/// Opaque message body.
bytes: Vec<u8>,
},
/// An event arrived from a separately versioned extension driver.
Source {
/// Registered extension source, such as a file watcher or serial port.
source_id: ResourceId,
/// Capability-defined event name.
event: String,
/// Capability-defined binary payload.
payload: Vec<u8>,
},
/// The deployment is draining and no later events should be accepted.
Shutdown,
}
@@ -105,39 +148,65 @@ pub enum ResidentEvent {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ResidentEffect {
/// Queue bytes for an open stream.
WriteStream {
/// Stream to write; it must belong to the current session.
stream_id: ResourceId,
/// One bounded write chunk.
bytes: Vec<u8>,
},
/// Ask the driver to close both halves of a stream.
CloseStream {
/// Stream to transition into `closing`.
stream_id: ResourceId,
},
/// Stop delivering read events until a matching resume effect.
PauseStream {
/// Stream whose read side should be paused.
stream_id: ResourceId,
},
/// Resume read delivery after Component backpressure clears.
ResumeStream {
/// Previously paused stream.
stream_id: ResourceId,
},
/// Send one UDP packet while preserving datagram boundaries.
SendDatagram {
/// Registered UDP endpoint used to send the packet.
endpoint_id: ResourceId,
/// Driver-formatted destination address.
peer: String,
/// Complete bounded datagram payload.
bytes: Vec<u8>,
},
/// Schedule or reschedule a registered Host timer.
ArmTimer {
/// Timer resource to schedule.
timer_id: ResourceId,
/// Delay from the time the Host applies this effect.
delay_ms: u64,
/// Optional repeat period; `None` creates a one-shot timer.
interval_ms: Option<u64>,
},
/// Cancel a registered timer.
CancelTimer {
/// Timer whose pending occurrence should be removed.
timer_id: ResourceId,
},
/// Confirm successful processing of a delivered message.
AcknowledgeMessage {
/// Subscription that produced the delivery.
subscription_id: ResourceId,
/// Exact message identity received in [`ResidentEvent::Message`].
message_id: u64,
},
/// Send a capability-defined command to an extension driver.
SourceCommand {
/// Extension source that should execute the command.
source_id: ResourceId,
/// Capability-defined command name.
command: String,
/// Capability-defined bounded binary payload.
payload: Vec<u8>,
},
}
+185 -8
View File
@@ -86,8 +86,13 @@ impl NetworkScope {
/// between validation and use.
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct ResidentPolicy {
/// Addresses on which TCP listener drivers may bind.
pub tcp_listen: NetworkScope,
/// Addresses on which UDP drivers may bind.
pub udp_bind: NetworkScope,
/// Canonical parent directories allowed to contain Unix listener sockets.
///
/// An empty list disables Unix endpoints.
pub unix_listen_roots: Vec<PathBuf>,
}
@@ -99,9 +104,27 @@ pub struct ResidentPolicy {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "transport", rename_all = "snake_case")]
pub enum ResidentEndpoint {
Tcp { name: String, bind: SocketAddr },
Udp { name: String, bind: SocketAddr },
Unix { name: String, path: PathBuf },
/// Host-owned TCP listener that produces byte-stream resources.
Tcp {
/// Unique operator-facing name within the session.
name: String,
/// Numeric address checked against [`ResidentPolicy::tcp_listen`].
bind: SocketAddr,
},
/// Host-owned UDP socket that preserves datagram boundaries.
Udp {
/// Unique operator-facing name within the session.
name: String,
/// Numeric address checked against [`ResidentPolicy::udp_bind`].
bind: SocketAddr,
},
/// Host-owned Unix stream listener.
Unix {
/// Unique operator-facing name within the session.
name: String,
/// Absolute socket path beneath an allowed canonical root.
path: PathBuf,
},
}
impl ResidentEndpoint {
@@ -116,12 +139,19 @@ impl ResidentEndpoint {
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ResidentResourceKind {
/// TCP listener endpoint.
TcpEndpoint,
/// UDP datagram endpoint.
UdpEndpoint,
/// Unix stream listener endpoint.
UnixEndpoint,
/// Accepted TCP or Unix byte stream.
Stream,
/// Host scheduler timer.
Timer,
/// Queue, broker, or internal mailbox subscription.
MessageSubscription,
/// Separately versioned extension driver.
Source,
}
@@ -129,20 +159,30 @@ pub enum ResidentResourceKind {
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ResidentResourceState {
/// Non-stream resource is registered and available to its driver.
Ready,
/// Stream accepts read and write activity.
Open,
/// Component backpressure has temporarily disabled read delivery.
ReadPaused,
/// Peer sent EOF; local writes are still permitted.
PeerHalfClosed,
/// Close was requested and only the terminal close event remains valid.
Closing,
}
/// Management-safe snapshot of one Host-owned resource.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ResidentResourceInfo {
/// Opaque identity scoped to one Actor revision.
pub id: ResourceId,
/// Operator-facing name used in status and diagnostics.
pub name: String,
/// Stable resource category.
pub kind: ResidentResourceKind,
/// Current state after all validated effects have been applied.
pub state: ResidentResourceState,
/// Driver correlation details that do not expose operating-system handles.
pub metadata: ResidentResourceMetadata,
}
@@ -150,11 +190,28 @@ pub struct ResidentResourceInfo {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ResidentResourceMetadata {
Endpoint { endpoint: ResidentEndpoint },
Stream { endpoint_id: ResourceId },
Timer { armed: bool },
/// Complete management configuration for a listener or datagram endpoint.
Endpoint {
/// Validated endpoint definition.
endpoint: ResidentEndpoint,
},
/// Parent listener of an accepted stream.
Stream {
/// TCP or Unix endpoint that accepted this stream.
endpoint_id: ResourceId,
},
/// Current scheduler status for a timer.
Timer {
/// Whether an arm operation is waiting to fire.
armed: bool,
},
/// Queue or broker details remain private to its driver.
MessageSubscription,
Source { source_kind: String },
/// Extension source identity.
Source {
/// Versioned capability name understood by the Component and driver.
source_kind: String,
},
}
/// An operation that passed payload, ownership, kind, and state validation.
@@ -164,39 +221,65 @@ pub enum ResidentResourceMetadata {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ResidentOperation {
/// Queue a bounded chunk on an open stream.
WriteStream {
/// Target stream resource.
stream_id: ResourceId,
/// Bytes to enqueue in order.
bytes: Vec<u8>,
},
/// Close a stream and later report its terminal close event.
CloseStream {
/// Target stream resource.
stream_id: ResourceId,
},
/// Disable driver reads without closing the stream.
PauseStream {
/// Target stream resource.
stream_id: ResourceId,
},
/// Re-enable reads on a previously paused stream.
ResumeStream {
/// Target stream resource.
stream_id: ResourceId,
},
/// Send one complete UDP datagram.
SendDatagram {
/// UDP endpoint used for the send.
endpoint_id: ResourceId,
/// Driver-formatted destination address.
peer: String,
/// Complete datagram payload.
bytes: Vec<u8>,
},
/// Create or replace a timer schedule.
ArmTimer {
/// Registered timer resource.
timer_id: ResourceId,
/// Delay measured from operation application.
delay_ms: u64,
/// Optional repeating interval.
interval_ms: Option<u64>,
},
/// Remove a pending timer schedule.
CancelTimer {
/// Registered timer resource.
timer_id: ResourceId,
},
/// Acknowledge a delivered message after Component processing succeeds.
AcknowledgeMessage {
/// Subscription that produced the message.
subscription_id: ResourceId,
/// Driver-scoped delivery identity.
message_id: u64,
},
/// Invoke a separately versioned extension driver command.
SourceCommand {
/// Target extension source.
source_id: ResourceId,
/// Capability-defined command name.
command: String,
/// Capability-defined binary payload.
payload: Vec<u8>,
},
}
@@ -204,51 +287,78 @@ pub enum ResidentOperation {
/// Host resource, policy, or Actor failure while processing resident work.
#[derive(Debug, Error)]
pub enum ResidentHostError {
/// Actor dispatch, deadline, trap, or Component contract failure.
#[error(transparent)]
Runtime(#[from] RuntimeError),
/// An event or resource registration was attempted after shutdown began.
#[error("resident session for {0} is shutting down")]
ShuttingDown(ServiceKey),
/// The Actor revision reached its configured Host-resource count.
#[error("resident resource limit for {service} is {limit}")]
ResourceLimit { service: ServiceKey, limit: usize },
ResourceLimit {
/// Actor revision that owns the full resource table.
service: ServiceKey,
/// Maximum simultaneous Host resources.
limit: usize,
},
/// The monotonic `u64` ID space was exhausted.
#[error("resident resource ids exhausted for {0}")]
ResourceIdsExhausted(ServiceKey),
/// An operator-facing endpoint or source name was empty.
#[error("resident resource name must not be empty")]
EmptyResourceName,
/// A named top-level resource conflicts with an existing name.
#[error("resident resource name {0} is already registered")]
DuplicateResourceName(String),
/// An endpoint cannot be released until all accepted streams are released.
#[error("resident resource {resource_id} for {service} still owns active streams")]
ResourceInUse {
/// Owning Actor revision.
service: ServiceKey,
/// Endpoint that still has child streams.
resource_id: u64,
},
/// An ID is unknown, released, or belongs to another session.
#[error("resident resource {resource_id} does not belong to {service}")]
UnknownResource {
/// Session in which the lookup was attempted.
service: ServiceKey,
/// Unknown opaque ID.
resource_id: u64,
},
/// An operation addressed the wrong category of resource.
#[error("resident resource {resource_id} for {service} is {actual:?}, expected {expected}")]
WrongResourceKind {
/// Owning Actor revision.
service: ServiceKey,
/// Addressed opaque ID.
resource_id: u64,
/// Actual category stored for the ID.
actual: ResidentResourceKind,
/// Human-readable category required by the operation.
expected: &'static str,
},
/// The resource exists but the requested transition is not legal.
#[error("resident resource {resource_id} for {service} is in invalid state {state:?}")]
InvalidResourceState {
/// Owning Actor revision.
service: ServiceKey,
/// Addressed opaque ID.
resource_id: u64,
/// State that rejected the transition.
state: ResidentResourceState,
},
/// A bind address or Unix path falls outside platform policy.
#[error("resident endpoint denied by policy: {0}")]
EndpointDenied(String),
}
@@ -333,6 +443,47 @@ impl From<StreamState> for ResidentResourceState {
/// This type intentionally performs no operating-system I/O. It makes driver
/// implementations replaceable and keeps Tokio or another async executor out
/// of the Wasmtime Actor thread.
///
/// # Driver example
///
/// ```no_run
/// use std::net::SocketAddr;
/// use wasmeld_runtime::{
/// ActorHandle, NetworkScope, ResidentEndpoint, ResidentOperation,
/// ResidentPolicy, ResidentSession,
/// };
///
/// # let actor: ActorHandle = todo!("start a resident Actor with Runtime::start");
/// let policy = ResidentPolicy {
/// tcp_listen: NetworkScope::Loopback,
/// ..ResidentPolicy::default()
/// };
/// let mut session = ResidentSession::new(actor, policy)?;
/// let endpoint_id = session.register_endpoint(ResidentEndpoint::Tcp {
/// name: "local-api".into(),
/// bind: "127.0.0.1:9000".parse::<SocketAddr>()?,
/// })?;
///
/// // The Tokio driver binds only after policy validation. For every accepted
/// // socket it allocates a stream, then applies returned operations in order.
/// let (stream_id, operations) =
/// session.accept_stream(endpoint_id, Some("127.0.0.1:51000".into()))?;
/// for operation in operations {
/// match operation {
/// ResidentOperation::WriteStream { stream_id, bytes } => {
/// // Queue `bytes` on the driver handle mapped to `stream_id`.
/// }
/// _ => { /* apply the other validated operation variants */ }
/// }
/// }
/// # let _ = stream_id;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
///
/// The session is deliberately `&mut self`: one supervisor task must serialize
/// all driver events. Do not put separate per-socket tasks around independent
/// copies of its state, because resource transitions and Component memory must
/// observe the same total order.
pub struct ResidentSession {
actor: ActorHandle,
policy: ResidentPolicy,
@@ -343,6 +494,9 @@ pub struct ResidentSession {
impl ResidentSession {
/// Creates a resource session for an Actor exporting resident WIT 0.1.0.
///
/// Resource IDs start at one and are never reused during this session.
/// Creating a new deployment revision requires a new session.
pub fn new(actor: ActorHandle, policy: ResidentPolicy) -> Result<Self, ResidentHostError> {
if actor.execution() != ComponentExecution::Resident {
return Err(RuntimeError::NotResident(actor.key().clone()).into());
@@ -362,6 +516,9 @@ impl ResidentSession {
}
/// Registers a configured listener or datagram endpoint after policy checks.
///
/// Registration does not bind an operating-system socket. The driver binds
/// after this succeeds and calls [`Self::release_resource`] if bind fails.
pub fn register_endpoint(
&mut self,
endpoint: ResidentEndpoint,
@@ -422,6 +579,9 @@ impl ResidentSession {
}
/// Allocates a stream for a connection accepted by a TCP or Unix driver.
///
/// If Component dispatch fails, the allocation is rolled back and the
/// caller must close the accepted operating-system socket.
pub fn accept_stream(
&mut self,
endpoint_id: ResourceId,
@@ -457,6 +617,10 @@ impl ResidentSession {
}
/// Delivers one bounded stream read. Paused and closing streams reject data.
///
/// Drivers must stop reading immediately after applying
/// [`ResidentOperation::PauseStream`]; queued data should remain in the
/// driver rather than bypassing Component backpressure.
pub fn stream_data(
&mut self,
stream_id: ResourceId,
@@ -490,6 +654,10 @@ impl ResidentSession {
}
/// Delivers terminal stream state and releases the resource identity.
///
/// The identity is released even when the Component callback fails because
/// terminal transport state is already an external fact. IDs remain
/// non-reusable, so a late driver event is rejected as unknown.
pub fn stream_closed(
&mut self,
stream_id: ResourceId,
@@ -521,6 +689,10 @@ impl ResidentSession {
}
/// Delivers a timer occurrence.
///
/// `scheduled_at_ns` is the driver's monotonic deadline, not wall time. The
/// timer is marked unarmed before dispatch; a returned arm operation creates
/// the next schedule.
pub fn timer_fired(
&mut self,
timer_id: ResourceId,
@@ -537,6 +709,9 @@ impl ResidentSession {
}
/// Delivers a broker or internal queue message.
///
/// A driver acknowledges externally only after it receives and successfully
/// applies [`ResidentOperation::AcknowledgeMessage`].
pub fn message(
&mut self,
subscription_id: ResourceId,
@@ -638,6 +813,8 @@ impl ResidentSession {
) -> Result<Vec<ResidentOperation>, ResidentHostError> {
// Validate the whole batch against a copy. A bad later effect must not
// leave pause, close, or timer state from an earlier effect committed.
// Drivers also receive operations only after this loop completes, so
// no external side effect can precede discovery of an invalid effect.
let mut next_resources = self.resources.clone();
let mut operations = Vec::with_capacity(effects.len());
for effect in effects {
+55 -2
View File
@@ -64,12 +64,22 @@ const ALLOWED_WASI_IMPORTS: &[&str] = &[
#[derive(Clone, Debug)]
pub struct RuntimeConfig {
/// Frequency used to advance Wasmtime epoch interruption.
///
/// Lower values make wall-time traps more precise but wake the ticker more
/// often. Service deadlines must be at least one tick.
pub epoch_tick: Duration,
/// Maximum native stack reservation for WebAssembly execution.
///
/// This is separate from Component linear-memory limits.
pub max_wasm_stack: usize,
/// Optional persistent cache for compiled Component machine code.
///
/// The directory is process-shared and contains derived artifacts only; it
/// may be deleted without losing registered packages.
pub component_cache_dir: Option<PathBuf>,
/// Service-scoped storage used by Components importing the KV capability.
///
/// Registration rejects a KV-importing Component when this is `None`.
pub kv_backend: Option<Arc<dyn KvBackend>>,
}
@@ -227,6 +237,9 @@ impl Runtime {
///
/// Imports are checked against the restricted WASI and explicit Wasmeld
/// capability allowlist before the service becomes visible.
///
/// Registration is immutable: another Component cannot replace the same
/// [`ServiceKey`]. Register a new revision and switch the deployment instead.
pub fn register(
&self,
manifest: ServiceManifest,
@@ -281,7 +294,11 @@ impl Runtime {
self.register(manifest, bytes)
}
/// Starts or returns the resident Actor for one registered service revision.
/// Starts or returns the Actor for one registered service revision.
///
/// This operation is idempotent while the Actor is healthy. When an Actor
/// already exists, the supplied `init_config` is ignored because `init` ran
/// exactly once for that instance.
pub fn start(
&self,
key: &ServiceKey,
@@ -348,6 +365,10 @@ impl Runtime {
}
/// Enqueues one call on the service Actor's bounded serial mailbox.
///
/// The deadline covers both time waiting behind earlier commands and Wasm
/// execution. A full mailbox returns [`RuntimeError::ActorOverloaded`]
/// immediately instead of blocking an unbounded number of callers.
pub fn invoke(&self, key: &ServiceKey, input: Vec<u8>) -> Result<Vec<u8>, RuntimeError> {
let actor = self
.inner
@@ -392,6 +413,10 @@ impl Runtime {
}
/// Delivers one Host event through the Actor's bounded serial mailbox.
///
/// This low-level method returns raw Component effects. Network and source
/// drivers should use [`crate::ResidentSession`] so resource ownership and
/// state are validated before any effect is executed.
pub fn dispatch_event(
&self,
key: &ServiceKey,
@@ -409,6 +434,10 @@ impl Runtime {
}
/// Stops one Actor and releases its Store and Component Instance.
///
/// Registered Component code remains available for a later [`Self::start`].
/// A resident deployment should first deliver shutdown and close its Host
/// resources through [`crate::ResidentSession`].
pub fn stop(&self, key: &ServiceKey) -> Result<(), RuntimeError> {
let _lifecycle = self
.inner
@@ -435,6 +464,10 @@ impl Runtime {
}
/// Registers a new revision and immediately starts its Actor.
///
/// This does not switch a deployment pointer or stop an older revision. If
/// Actor initialization fails, the new revision remains registered so the
/// management layer can inspect or explicitly unregister it.
pub fn reload(
&self,
manifest: ServiceManifest,
@@ -587,6 +620,10 @@ impl ActorHandle {
}
/// Sends an invocation and waits up to the remaining service deadline.
///
/// Cloned handles compete for the same bounded mailbox. A Wasm trap is
/// fatal for the Actor, while a typed WIT `call-failed` result is returned
/// as [`RuntimeError::ComponentError`] and leaves the Actor available.
pub fn invoke(&self, input: Vec<u8>) -> Result<Vec<u8>, RuntimeError> {
if !self.is_available() {
return Err(RuntimeError::ActorUnavailable(self.key.clone()));
@@ -629,7 +666,10 @@ impl ActorHandle {
}
}
/// Sends a Host-owned resource event and waits for validated effects.
/// Sends a Host-owned resource event and waits for size-validated effects.
///
/// Resource ownership and stream-state checks happen later in
/// [`crate::ResidentSession`]; drivers should not execute this raw result.
pub fn dispatch_event(
&self,
event: ResidentEvent,
@@ -669,6 +709,9 @@ impl ActorHandle {
}
/// Prevents new calls, asks the worker to stop, and waits for acknowledgement.
///
/// Stop is not a graceful resident drain. The owner must first stop external
/// event production, deliver shutdown, and close Host resources.
pub fn stop(&self) -> Result<(), RuntimeError> {
if !self.status.accepting.swap(false, Ordering::AcqRel) {
return Err(RuntimeError::ActorUnavailable(self.key.clone()));
@@ -765,6 +808,10 @@ impl HostState {
.build();
let mut wasi = WasiCtxBuilder::new();
// Network access is denied twice: socket interfaces are absent from the
// linker allowlist, and the WASI context rejects TCP, UDP, DNS, and
// address checks. Resident network drivers live outside the Store and
// expose only opaque resource events.
wasi.allow_tcp(false)
.allow_udp(false)
.allow_ip_name_lookup(false)
@@ -839,6 +886,8 @@ impl ActorWorker {
add_restricted_wasi(&mut linker)?;
CapabilityRegistry::add_to_linker(&mut linker, &service.capabilities)?;
// Fuel limits deterministic instruction work; epoch interruption limits
// elapsed wall time. Both are reset before init and every later call.
let epoch_ticks = ticks_for(limits.deadline(), epoch_tick);
configure_call_budget(&mut store, &limits, epoch_ticks)?;
let instance = linker
@@ -1082,6 +1131,8 @@ fn spawn_actor(
})
.map_err(RuntimeError::ActorThread)?;
// Do not publish a handle until instantiation and guest init completed.
// This prevents callers from observing an Actor that cannot serve work.
ready_receiver.recv().map_err(|_| {
RuntimeError::ActorInitialization("actor thread stopped during initialization".to_owned())
})??;
@@ -1160,6 +1211,8 @@ fn run_actor(worker: &mut ActorWorker, receiver: Receiver<ActorCommand>, status:
}
fn drain_after_failure(receiver: Receiver<ActorCommand>, key: &ServiceKey) {
// A trap invalidates the Store. Explicitly answer queued callers so they do
// not wait for their individual deadlines after the worker exits.
while let Ok(command) = receiver.try_recv() {
match command {
ActorCommand::Invoke {
+7
View File
@@ -1,9 +1,16 @@
package wasmeld:clock@0.1.0;
/// Actor-local monotonic time for measuring elapsed durations.
interface monotonic-clock {
/// Returns nanoseconds elapsed since this Actor Store was created.
///
/// The value is monotonic and saturates at `u64::MAX`. It is not Unix time,
/// cannot be compared across Actor restarts, and must not be persisted as a
/// wall-clock timestamp.
now: func() -> u64;
}
/// Host-side world used by Wasmeld to link the clock implementation.
world clock-host {
import monotonic-clock;
}
+20
View File
@@ -1,17 +1,37 @@
package wasmeld:kv@0.1.0;
/// Small persistent byte-value store scoped to the stable service ID.
///
/// Revisions of the same service share a namespace, so a deployment change
/// preserves values. Keys are non-empty UTF-8 strings of at most 256 bytes and
/// values are at most 64 KiB in this version of the Host implementation.
/// Operations are not multi-key transactions.
interface store {
/// Expected validation or backend failures.
variant kv-error {
/// The key is empty or exceeds the Host byte limit.
invalid-key(string),
/// The attempted or stored value exceeds the Host byte limit.
value-too-large(u64),
/// Persistence failed, timed out, or was temporarily unavailable.
///
/// A timeout is not cancellation: a mutation may finish after the caller
/// receives this error. Retried `set` and `delete` operations should be
/// idempotent.
operation-failed(string),
}
/// Returns the current value, or `none` when the key does not exist.
get: func(key: string) -> result<option<list<u8>>, kv-error>;
/// Creates or replaces one value.
set: func(key: string, value: list<u8>) -> result<_, kv-error>;
/// Removes one value; deleting a missing key succeeds.
delete: func(key: string) -> result<_, kv-error>;
}
/// Host-side world used by Wasmeld to link persistent KV.
world kv-host {
import store;
}
+60
View File
@@ -5,43 +5,61 @@ package wasmeld:resident@0.1.0;
/// and all operating-system handles. A Component handles one bounded event and
/// returns effects before control goes back to the Runtime.
interface actor {
/// Opaque, nonzero identity allocated by the Host for one resident session.
///
/// IDs are scoped to one service revision and session. They are never OS
/// handles, must not be guessed, and become invalid after Host release.
type resource-id = u64;
/// One occurrence of a previously registered and armed timer.
record timer-fired {
timer-id: resource-id,
/// Driver monotonic deadline in nanoseconds, not wall-clock time.
scheduled-at-ns: u64,
}
/// A stream accepted by a previously registered TCP or Unix endpoint.
record stream-opened {
endpoint-id: resource-id,
stream-id: resource-id,
/// Driver-formatted peer address when the transport provides one.
peer: option<string>,
}
/// One bounded read chunk. Chunk boundaries are not message boundaries.
record stream-chunk {
stream-id: resource-id,
bytes: list<u8>,
}
/// Terminal reason reported by the Host driver.
enum close-reason {
/// The peer closed the transport normally.
peer-closed,
/// The platform or Component requested closure.
host-closed,
/// The configured inactivity deadline elapsed.
idle-timeout,
/// Protocol validation failed above the transport layer.
protocol-error,
/// The operating-system transport returned an error.
transport-error,
}
/// Final event for a stream identity.
record stream-closed {
stream-id: resource-id,
reason: close-reason,
}
/// One UDP datagram. A datagram is never split across events.
record datagram {
endpoint-id: resource-id,
peer: string,
bytes: list<u8>,
}
/// One delivery from a Host-owned broker or internal queue.
record message {
subscription-id: resource-id,
message-id: u64,
@@ -59,65 +77,107 @@ interface actor {
}
variant event {
/// A registered timer reached its monotonic deadline.
timer(timer-fired),
/// A Host driver accepted a new stream.
stream-opened(stream-opened),
/// A stream produced one bounded read chunk.
stream-data(stream-chunk),
/// The driver can accept writes after previous backpressure.
stream-writable(resource-id),
/// The peer closed its write half; local writes may still be valid.
stream-half-closed(resource-id),
/// The stream is terminal and its identity will be released.
stream-closed(stream-closed),
/// A UDP endpoint received one datagram.
datagram(datagram),
/// A subscription delivered one message.
message(message),
/// A separately versioned extension source produced an event.
source(source-event),
/// The session is stopping. No later events will be delivered.
shutdown,
}
/// Request to enqueue bytes for a stream driver.
record stream-write {
stream-id: resource-id,
bytes: list<u8>,
}
/// Request to send one datagram through a registered UDP endpoint.
record datagram-send {
endpoint-id: resource-id,
peer: string,
bytes: list<u8>,
}
/// Request to arm or replace a registered timer schedule.
record timer-arm {
timer-id: resource-id,
/// Delay from operation application, in milliseconds.
delay-ms: u64,
/// Optional repeating interval in milliseconds.
interval-ms: option<u64>,
}
/// Request to acknowledge a previously delivered message.
record message-ack {
subscription-id: resource-id,
message-id: u64,
}
/// Driver-specific command for a separately versioned extension source.
record source-command {
source-id: resource-id,
command: string,
payload: list<u8>,
}
/// Host operations requested after handling one event.
///
/// Effects are declarations, not completed I/O. The Host validates the
/// entire returned list before applying any item, then the external driver
/// performs the operations in order.
variant effect {
/// Enqueue bytes for an open stream.
write-stream(stream-write),
/// Begin closing an open stream.
close-stream(resource-id),
/// Stop delivering new read data until resumed.
pause-stream(resource-id),
/// Resume delivery after a matching pause.
resume-stream(resource-id),
/// Send one UDP datagram.
send-datagram(datagram-send),
/// Arm or replace one timer.
arm-timer(timer-arm),
/// Disarm one timer; its identity remains registered.
cancel-timer(resource-id),
/// Acknowledge one broker or queue message.
acknowledge-message(message-ack),
/// Forward an extension-specific command.
source-command(source-command),
}
/// Expected Component failure while processing a resident event.
variant resident-error {
event-failed(string),
}
/// Handles exactly one serialized, bounded event.
///
/// This callback must not run an internal infinite loop or block on network
/// I/O. Update Component state, return requested effects, and let the Host
/// wait for the next external event. A trap faults the Actor; `event-failed`
/// reports an application error without changing the ABI.
handle-event: func(input: event) -> result<list<effect>, resident-error>;
}
/// Optional export world for Components that need long-lived Host-driven I/O.
///
/// A Component may compose this package with `wasmeld:service` and only the
/// capability packages it actually imports.
world resident-component {
export actor;
}
+23
View File
@@ -1,11 +1,34 @@
package wasmeld:service@0.1.0;
/// Minimum request/response lifecycle implemented by every Wasmeld service.
///
/// The Runtime creates one Component Store per Actor, calls `init` once, then
/// serializes `invoke` calls through that Actor's bounded mailbox. Component
/// memory can therefore remain warm between calls, but it is not durable and
/// is lost when the Actor or process restarts.
world service-component {
/// Application-level failures returned without trapping the Component.
///
/// Traps, fuel exhaustion, deadline interruption, and invalid ABI data are
/// Runtime failures and do not use this variant.
variant service-error {
/// The Actor cannot accept calls because its initial configuration failed.
init-failed(string),
/// This input could not be processed; a later call may still succeed.
call-failed(string),
}
/// Initializes a newly created Actor Store.
///
/// `config` is opaque to the platform. Return `init-failed` for expected
/// configuration errors; do not retain borrowed views into this byte list.
export init: func(config: list<u8>) -> result<_, service-error>;
/// Processes one opaque request and returns one opaque response.
///
/// The management API represents these bytes as base64, while the public
/// gateway transports them as `application/octet-stream`. The Component
/// should return promptly and use the resident actor contract for long-lived
/// I/O instead of blocking this call in an internal loop.
export invoke: func(input: list<u8>) -> result<list<u8>, service-error>;
}