From 5622fd965ffc4a94bcd8debfb29a1e9c5b170e09 Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Fri, 6 Nov 2026 16:14:53 +0800 Subject: [PATCH] SpaceWASM firmware support --- .github/workflows/no-std.yaml | 13 +- .mise/config.rust.toml | 11 +- CHANGELOG.md | 7 + Cargo.lock | 16 +- libs/core-guest/src/abi.rs | 2 + services/firmware-modules/Cargo.toml | 1 + services/firmware-modules/src/lib.rs | 178 +++++++---- services/firmware-modules/tests/guests.rs | 94 ++++-- services/ws-server-firmware/Cargo.toml | 2 +- services/ws-server-firmware/README.md | 9 +- services/ws-server-firmware/build.rs | 7 +- services/ws-server-firmware/src/board/hub.rs | 3 +- .../ws-server-firmware/src/board/remote.rs | 3 +- services/ws-server-firmware/src/host.rs | 8 +- services/ws-server-firmware/src/main.rs | 1 + services/ws-server-firmware/src/runner.rs | 64 +--- services/ws-wasi-runner/README.md | 10 +- services/ws-wasi-runner/src/board/host.rs | 155 +++++++-- services/ws-wasi-runner/src/board/memory.rs | 60 ++++ services/ws-wasi-runner/src/board/mod.rs | 9 +- services/ws-wasi-runner/src/board/run.rs | 11 + services/ws-wasi-runner/src/board/run_core.rs | 39 +++ .../ws-wasi-runner/src/board/run_guest.rs | 99 ++++++ .../src/core_module/allocator.rs | 2 +- .../ws-wasi-runner/src/core_module/header.rs | 13 + .../ws-wasi-runner/src/core_module/mod.rs | 10 +- .../ws-wasi-runner/src/core_module/run.rs | 32 +- services/ws-wasi-runner/tests/board.rs | 299 +++++++++++++++++- services/ws-wasi-runner/tests/core_module.rs | 9 +- 29 files changed, 932 insertions(+), 235 deletions(-) create mode 100644 services/ws-wasi-runner/src/board/run_core.rs create mode 100644 services/ws-wasi-runner/src/board/run_guest.rs create mode 100644 services/ws-wasi-runner/src/core_module/header.rs diff --git a/.github/workflows/no-std.yaml b/.github/workflows/no-std.yaml index 2d7feba4..3b84c636 100644 --- a/.github/workflows/no-std.yaml +++ b/.github/workflows/no-std.yaml @@ -84,11 +84,12 @@ jobs: mise run cargo-clippy-check-esp32c6 mise run firmware-scenario verification/local/input/default.yaml firmware-esp32c6 - # The board with PSRAM runs wasi runners beside the hub, so its scenario is the two math1 runners. - # Their components have to be built before the firmware compiles them for the board. The host build then runs the - # same guests against the same hub. - - name: Lint the Pico Plus 2 W firmware, then build and serve a generated scenario's runners - timeout-minutes: 45 + # The board with PSRAM runs wasi runners beside the hub, so its scenarios are the math1 runners. + # First two components, then the trigger with its core-module twin, which the board runs on its interpreter + # rather than its engine. Their modules have to be built before the firmware builds them in. The host build then + # runs the same guests against the same hub. + - name: Lint the Pico Plus 2 W firmware, then build and serve generated scenarios' runners + timeout-minutes: 60 env: WIFI_NETWORK: edge-toolkit-ci WIFI_PASSWORD: not-a-real-passphrase @@ -97,4 +98,6 @@ jobs: mise run build-ws-wasm-agent mise run build-ws-wasi-math1-module mise run build-ws-wasi-math1-sender-module + mise run build-ws-core-math1-module mise run firmware-scenario verification/local/input/wasi-math1.yaml firmware-pico-plus2 --host-test + mise run firmware-scenario verification/local/input/core-math1.yaml firmware-pico-plus2 --host-test diff --git a/.mise/config.rust.toml b/.mise/config.rust.toml index 676a4f44..de33c179 100644 --- a/.mise/config.rust.toml +++ b/.mise/config.rust.toml @@ -102,14 +102,17 @@ shell = "{{ vars.task_shell }}" # The crates that build without std, built for the Pico 2 W's bare-metal target, which has no std to fall back on. # A host build of the same features can still link std through a dependency without anyone noticing; this cannot. +# The runner is built on its own, its features named bare: it depends on itself for its tests, so to cargo +# `et-ws-wasi-runner/board` names that dependency's feature, which a library build never turns on. [tasks.build-no-std] description = "Build the no_std crates for a bare-metal target" run = """ -features=et-modules-service/board,et-storage-service/board,et-ws-service/board,et-ws-wasi-runner/board -features="$features,et-ws-wasi-runner/core-modules" -set -- -p et-org -p edge-toolkit -p et-modules-service -p et-storage-service -p et-ws-service -p et-ws-wasi-runner +target=thumbv8m.main-none-eabihf +features=et-modules-service/board,et-storage-service/board,et-ws-service/board +set -- -p et-org -p edge-toolkit -p et-modules-service -p et-storage-service -p et-ws-service set -- "$@" -p et-core-guest -p et-math1 -cargo build --lib --target thumbv8m.main-none-eabihf --no-default-features --features "$features" "$@" +cargo build --lib --target "$target" --no-default-features --features "$features" "$@" +cargo build --lib --target "$target" --no-default-features --features board,core-modules -p et-ws-wasi-runner """ shell = "{{ vars.task_shell }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 2604d339..45c7c9ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ Changelog 1.1.0. crate defines and wraps in a typed guest API for the `wasm32v1-none` target; `core-math1` is the first such module, and `verification/local/input/core-math1.yaml` pairs it with the WASI trigger. A runner built without the feature fails a core module with `RunnerError::CoreModulesDisabled`. +- A board runs Wasm 1.0 core modules too. `et-ws-wasi-runner`'s `board::run_core_module` hosts one's `et` imports on + the board's `Platform` and `Transport`, its memory drawn from the same `MemoryBudget` as a component's, and + `core_module::is_core_module` tells one from a component or a precompiled guest by its header. `board::run_guest` + runs a guest of either kind, rerunning it after `board::RERUN_DELAY` when it fails in itself. `RUNNER_GUESTS` may + name a core module for et-ws-server-firmware, whose build checks its imports against the `et` functions, now listed + as `et_core_guest::abi::IMPORTS`, and builds it in as its package holds it, with nothing compiled ahead of time. + `core-math1`'s `firmware-pico-plus2` deployment now builds. - `et-math1` holds the math1 family's `FedAvg` kernel, input pointer and output JSON, which the Rust math1 twins and triggers now share rather than each carrying a copy. - `generated/specs/rest.md` and `generated/specs/ws.md` document the REST API and the WebSocket protocol in Markdown: diff --git a/Cargo.lock b/Cargo.lock index 83c837ea..f013c530 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,9 +21,9 @@ dependencies = [ [[package]] name = "actix-files" -version = "0.7.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db96438ec043d438f36bc211e260f0bec3de11dd0921feebe6b776e5a77ff3f9" +checksum = "c6f3a6769c3bc1c7d8e04b5c15f0c87489924c93455d9820296c219475a9051a" dependencies = [ "actix-http", "actix-service", @@ -45,17 +45,18 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.20.0" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d20a22850ea7fecad12c5f8cd97d246c3af1f04ef400033977c2f13425c45816" +checksum = "cd4ef186188fed2d4f8177d712ad623c751c6e84f45b31da694cc53f483f320b" dependencies = [ "actix-codec", "actix-service", "actix-tls", "actix-utils", - "base64 0.22.1", + "base64 0.23.1", "bitflags 2.13.2", "brotli 9.1.1", + "brotli-decompressor 6.2.0", "bytes", "bytestring", "derive_more", @@ -176,9 +177,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.18.1" +version = "4.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f8da0d2ae4200dde8773d72ba47a65c220b251ba7b9248db5bd263c247e959" +checksum = "697d10a06f7a550791dcb963e3090aa5c65c4d94c029114814a4bd652f30f0b4" dependencies = [ "actix-codec", "actix-http", @@ -5779,6 +5780,7 @@ name = "et-firmware-modules" version = "0.1.0" dependencies = [ "edge-toolkit", + "et-core-guest", "et-ws-wasi-runner", "fs-err", "serde_json", diff --git a/libs/core-guest/src/abi.rs b/libs/core-guest/src/abi.rs index cde44071..24a4a3a4 100644 --- a/libs/core-guest/src/abi.rs +++ b/libs/core-guest/src/abi.rs @@ -23,6 +23,8 @@ pub const KV_GET: &str = "kv_get"; pub const KV_SET: &str = "kv_set"; /// Record one log entry. pub const LOG: &str = "log"; +/// Every function the module provides, which is all a guest may import from it. +pub const IMPORTS: [&str; 9] = [CONNECT, AGENT_ID, SEND, RECV, TAKE, DISCONNECT, KV_GET, KV_SET, LOG]; /// The call succeeded, or `run` did. pub const SUCCESS: i32 = 0; diff --git a/services/firmware-modules/Cargo.toml b/services/firmware-modules/Cargo.toml index c1811bac..8099ef63 100644 --- a/services/firmware-modules/Cargo.toml +++ b/services/firmware-modules/Cargo.toml @@ -12,6 +12,7 @@ doctest = false [dependencies] edge-toolkit = { workspace = true, features = ["std"] } +et-core-guest.workspace = true et-ws-wasi-runner = { workspace = true, features = ["precompile"] } fs-err.workspace = true serde_json = { workspace = true, features = ["std"] } diff --git a/services/firmware-modules/src/lib.rs b/services/firmware-modules/src/lib.rs index 6b8628c6..6c11947d 100644 --- a/services/firmware-modules/src/lib.rs +++ b/services/firmware-modules/src/lib.rs @@ -13,6 +13,7 @@ use std::hash::{DefaultHasher, Hash as _, Hasher as _}; use std::path::{Component, Path, PathBuf}; use edge_toolkit::board::Guest; +use et_core_guest::abi; use et_ws_wasi_runner::engine::precompile; use fs_err as fs; @@ -80,15 +81,13 @@ pub enum ModulesError { package: String, names: Vec, }, - #[error("runner {runner} runs {package}, whose main {main:?} is not a WebAssembly component")] + #[error("runner {runner} runs {package}, whose main {main:?} is not a WebAssembly component or core module")] NotAComponent { runner: String, package: String, main: String, }, - #[error("runner {runner} runs {package}, a Wasm 1.0 core module, which a board's runner cannot run")] - CoreModule { runner: String, package: String }, - #[error("{path} is not a WebAssembly component")] + #[error("{path} is not a WebAssembly component or core module a board can read")] Unreadable { path: PathBuf, #[source] @@ -184,20 +183,22 @@ static MODULES: {MODULES_API}::Modules = {MODULES_API}::Modules::new( }) } -/// The source building in a guest for each runner in `guests`, compiled for `target`, and checked against the board. +/// The source building in a guest for each runner in `guests`, checked against the board, and ready for it to run. /// /// `guests` is a comma-separated list of `runner=package` pairs, as `RUNNER_GUESTS` holds it, each naming a module in -/// `paths` whose `main` is the component to run. A board hosts only what [`edge_toolkit::board::HOSTED_IMPORTS`] lists, -/// and holds `memory` bytes for its guests' memories and tables, so a guest importing anything else, or guests starting -/// with more between them, are refused here rather than when the board instantiates them. A table element is counted -/// at the pointer width of `target`, as the board's runner charges it. Each compiled guest is written to `out_dir`, and -/// the source defines `const GUESTS`, every runner's name and its compiled component, as an array so the firmware can -/// size a task pool by it. +/// `paths` whose `main` is the guest to run: a component, which is compiled for `target` and written to `out_dir`, or +/// a Wasm 1.0 core module, which the board's interpreter decodes as it is and so is built in as its package holds it. +/// A board hosts only what [`edge_toolkit::board::HOSTED_IMPORTS`] lists for a component and the `et` functions for a +/// core module, and holds `memory` bytes for its guests' memories and tables, so a guest importing anything else, or +/// guests starting with more between them, are refused here rather than when the board instantiates them. A component's +/// table element is counted at the pointer width of `target`, as the board's runner charges it; a core module's tables +/// are not counted, as its interpreter keeps them beside its own state. The source defines `const GUESTS`, every +/// runner's name and its guest, as an array so the firmware can size a task pool by it. /// /// # Errors /// -/// When a pair is malformed, a package is not in `paths` or its `main` is not a readable component, a guest imports -/// what the board does not host, the guests need more than `memory`, or one does not compile for `target`. +/// When a pair is malformed, a package is not in `paths` or its `main` is not readable WebAssembly, a guest imports +/// what the board does not host, the guests need more than `memory`, or a component does not compile for `target`. #[expect( clippy::use_debug, reason = "a str's Debug form is the Rust literal the generated source needs" @@ -210,33 +211,39 @@ pub fn guests(paths: &str, guests: &str, target: &str, memory: u64, out_dir: &Pa .collect::, _>>()?; let pointer_bytes = if target == PULLEY32 { 4 } else { 8 }; let mut entries = String::default(); - let mut components = Vec::new(); + let mut mains = Vec::new(); let mut needed: u64 = 0; for (index, pair) in items(guests).enumerate() { - let (runner, component, bytes, guest) = checked_guest(pair, &packages)?; - needed = needed.saturating_add(guest.starts_with(pointer_bytes)); - // Matched rather than converted, so the error can name the runner whose guest would not compile. - let compiled = match precompile(target, &bytes) { - Ok(compiled) => compiled, - Err(error) => { - return Err(ModulesError::Precompile { - runner: runner.to_owned(), - reason: error.to_string(), - }); - } + let (runner, main, bytes) = found_guest(pair, &packages)?; + let built_in = if wasmparser::Parser::is_core_wasm(&bytes) { + needed = needed.saturating_add(checked_core_module(runner, &main, &bytes)?); + include_path(MODULES_PATHS, &main)? + } else { + let guest = checked_component(runner, &main, &bytes)?; + needed = needed.saturating_add(guest.starts_with(pointer_bytes)); + // Matched rather than converted, so the error can name the runner whose guest would not compile. + let compiled = match precompile(target, &bytes) { + Ok(compiled) => compiled, + Err(error) => { + return Err(ModulesError::Precompile { + runner: runner.to_owned(), + reason: error.to_string(), + }); + } + }; + let compiled_path = out_dir.join(format!("guest-{index}.cwasm")); + fs::write(&compiled_path, compiled)?; + include_path(RUNNER_GUESTS, &compiled_path)? }; - let compiled_path = out_dir.join(format!("guest-{index}.cwasm")); - fs::write(&compiled_path, compiled)?; - let literal = compiled_path.display().to_string(); - let _written = writeln!(entries, " ({runner:?}, include_bytes!({literal:?})),"); - components.push(component); + let _written = writeln!(entries, " ({runner:?}, include_bytes!({built_in})),"); + mains.push(main); } if needed > memory { return Err(ModulesError::GuestMemory { needed, budget: memory }); } - let count = components.len(); + let count = mains.len(); let source = format!( - "/// The guests the firmware runs beside the hub, each compiled for it, as `RUNNER_GUESTS` named them. + "/// The guests the firmware runs beside the hub, each ready for it to run, as `RUNNER_GUESTS` named them. pub(crate) const GUESTS: [(&str, &[u8]); {count}] = [ {entries}]; /// The bytes the board holds for its guests' memories, which they draw on as they grow, whatever they start with. @@ -244,10 +251,7 @@ pub(crate) const GUEST_MEMORY: u64 = {budget}; ", budget = separated(memory) ); - Ok(Generated { - source, - files: components, - }) + Ok(Generated { source, files: mains }) } /// `value` as an integer literal with its digits in groups of three, as the firmware's lints ask of a long one. @@ -311,17 +315,12 @@ fn runnable(dir: &Path) -> Result { }) } -/// The runner `pair` names, once a board can host its component. -/// -/// With it, the component's path and bytes, and what the component asks of the board. +/// The runner `pair` names, with the path and bytes of the `main` its package names. #[expect( clippy::single_call_fn, - reason = "the per-runner check of guests, kept apart from compiling" + reason = "the per-runner lookup of guests, kept apart from checking what it found" )] -fn checked_guest<'pair>( - pair: &'pair str, - packages: &[Runnable], -) -> Result<(&'pair str, PathBuf, Vec, Guest), ModulesError> { +fn found_guest<'pair>(pair: &'pair str, packages: &[Runnable]) -> Result<(&'pair str, PathBuf, Vec), ModulesError> { let (runner, package) = pair .split_once('=') .map(|(runner, package)| (runner.trim(), package.trim())) @@ -341,32 +340,75 @@ fn checked_guest<'pair>( main: found.main.clone(), }); } - let bytes = fs::read(&found.component)?; - // A core module's own imports read as none to `Guest::read`, which looks only for a component's, so it would pass - // every check here and fail in the component compiler instead, saying nothing of why. - if wasmparser::Parser::is_core_wasm(&bytes) { - return Err(ModulesError::CoreModule { - runner: runner.to_owned(), - package: package.to_owned(), - }); - } - let guest = match Guest::read(&bytes) { - Ok(guest) => guest, - Err(source) => { - return Err(ModulesError::Unreadable { - path: found.component.clone(), - source, - }); + Ok((runner, found.component.clone(), fs::read(&found.component)?)) +} + +/// What the component `bytes`, `runner`'s guest at `path`, asks of the board, once the board can host it. +#[expect( + clippy::single_call_fn, + reason = "the component check of guests, kept apart from compiling" +)] +fn checked_component(runner: &str, path: &Path, bytes: &[u8]) -> Result { + let guest = readable(path, Guest::read(bytes))?; + hosted(runner, guest.unhosted().map(str::to_owned).collect())?; + Ok(guest) +} + +/// The bytes the core module `bytes`, `runner`'s guest at `path`, starts its memories with, once the board can host it. +#[expect( + clippy::single_call_fn, + reason = "the core module check of guests, beside the component one" +)] +fn checked_core_module(runner: &str, path: &Path, bytes: &[u8]) -> Result { + hosted(runner, readable(path, unhosted_core_imports(bytes))?)?; + // A core module's memories are what `Guest::read` counts of one, as it imports nothing it would count. + Ok(readable(path, Guest::read(bytes))?.memory) +} + +/// The imports of the core module `bytes` that a board does not host, each as `module::name`. +/// +/// A board provides only the functions of the `et` module, so anything else, or one of their names imported as other +/// than a function, is not hosted. +#[expect( + clippy::single_call_fn, + reason = "the import scan of checked_core_module, named for what it returns" +)] +fn unhosted_core_imports(bytes: &[u8]) -> wasmparser::Result> { + let mut unhosted = Vec::new(); + for payload in wasmparser::Parser::new(0).parse_all(bytes) { + if let wasmparser::Payload::ImportSection(section) = payload? { + for import in section.into_imports() { + let import = import?; + let function = matches!(import.ty, wasmparser::TypeRef::Func(_)); + if !(function && import.module == abi::MODULE && abi::IMPORTS.contains(&import.name)) { + unhosted.push(format!("{}::{}", import.module, import.name)); + } + } } - }; - let unhosted: Vec = guest.unhosted().map(str::to_owned).collect(); - if !unhosted.is_empty() { - return Err(ModulesError::Unhosted { - runner: runner.to_owned(), - imports: unhosted, - }); } - Ok((runner, found.component.clone(), bytes, guest)) + Ok(unhosted) +} + +/// What `read` read of the guest at `path`, or that it is not WebAssembly a board can read. +fn readable(path: &Path, read: wasmparser::Result) -> Result { + match read { + Ok(read) => Ok(read), + Err(source) => Err(ModulesError::Unreadable { + path: path.to_path_buf(), + source, + }), + } +} + +/// Refuse `runner`'s guest when it imports any of `unhosted`, which the board does not host. +fn hosted(runner: &str, unhosted: Vec) -> Result<(), ModulesError> { + if unhosted.is_empty() { + return Ok(()); + } + Err(ModulesError::Unhosted { + runner: runner.to_owned(), + imports: unhosted, + }) } /// `path` as the literal an `include_bytes!` names it by, which has to be absolute; `variable` is where it came from. diff --git a/services/firmware-modules/tests/guests.rs b/services/firmware-modules/tests/guests.rs index 20aeead7..8df5eb40 100644 --- a/services/firmware-modules/tests/guests.rs +++ b/services/firmware-modules/tests/guests.rs @@ -1,5 +1,6 @@ -//! Covers the `guests.rs` source a firmware build writes: each runner's component found among the modules built in, -//! checked against what a board hosts and holds, and compiled for it, and every way a guest is refused. +//! Covers the `guests.rs` source a firmware build writes: each runner's guest found among the modules built in, checked +//! against what a board hosts and holds, a component compiled for it and a core module built in as it is, and every way +//! a guest is refused. #![cfg(test)] use std::path::{Path, PathBuf}; @@ -132,22 +133,63 @@ fn a_runner_of_a_module_with_no_component_is_refused() { } #[test] -fn a_component_that_is_not_webassembly_is_refused() { - let root = tempdir().unwrap(); - let dir = module(root.path(), "guest", "guest.wasm", b"not wasm"); +fn a_guest_that_is_not_webassembly_a_board_can_read_is_refused() { + // Not WebAssembly at all; a core module's header, then an import section claiming more bytes than follow; and one + // whose imports read, declaring a memory with no limits after it. + for main in [ + b"not wasm".as_slice(), + b"\0asm\x01\0\0\0\x02\x7f", + b"\0asm\x01\0\0\0\x05\x01\x01", + ] { + let root = tempdir().unwrap(); + let dir = module(root.path(), "guest", "guest.wasm", main); + let out_dir = tempdir().unwrap(); + let refused = one_guest(&dir.display().to_string(), ROOMY, out_dir.path()).unwrap_err(); + assert!(matches!(refused, ModulesError::Unreadable { path, .. } if path == dir.join("guest.wasm"))); + } +} + +#[test] +fn a_runner_s_core_module_is_built_in_as_its_package_holds_it() { + let core_math1 = get_project_root().join("services/ws-modules/core-math1"); let out_dir = tempdir().unwrap(); - let refused = one_guest(&dir.display().to_string(), ROOMY, out_dir.path()).unwrap_err(); - assert!(matches!(refused, ModulesError::Unreadable { path, .. } if path == dir.join("guest.wasm"))); + let paths = format!("{}", core_math1.display()); + let generated = guests( + &paths, + "twin = @edge-toolkit/et-ws-core-math1", + TARGET, + ROOMY, + out_dir.path(), + ) + .unwrap(); + // Joined a segment at a time, as the build joins it, so on Windows the expected literal has a backslash there too. + let module = core_math1.join("pkg").join("et_ws_core_math1.wasm"); + assert!(!out_dir.path().join("guest-0.cwasm").exists()); + assert!(generated.source.contains(&format!( + "(\"twin\", include_bytes!({:?})),", + module.display().to_string() + ))); + assert_eq!(generated.files, [module]); } #[test] -fn a_core_module_is_refused() { - let (_root, path) = component_module(r#"(module (func (export "run") (result i32) (i32.const 0)))"#); +fn a_core_module_importing_what_the_board_does_not_host_is_refused() { + let (_root, path) = component_module( + r#"(module + (import "et" "connect" (func (result i32))) + (import "wasi_snapshot_preview1" "fd_write" (func (param i32 i32 i32 i32) (result i32))) + (import "et" "log" (global i32)) + (import "et" "sleep_ms" (func (param i32))) + (func (export "run") (result i32) (i32.const 0)) + )"#, + ); let out_dir = tempdir().unwrap(); let refused = one_guest(&path, ROOMY, out_dir.path()).unwrap_err(); - let expected = - matches!(refused, ModulesError::CoreModule { runner, package } if runner == "runner-1" && package == "guest"); - assert!(expected); + let unhosted = ["wasi_snapshot_preview1::fd_write", "et::log", "et::sleep_ms"]; + assert!(matches!( + refused, + ModulesError::Unhosted { runner, imports } if runner == "runner-1" && imports == unhosted + )); } #[test] @@ -169,17 +211,23 @@ fn a_guest_importing_what_the_board_does_not_host_is_refused() { #[test] fn guests_needing_more_memory_than_the_board_holds_are_refused() { - let (_root, path) = component_module("(component (core module (memory 2)))"); - let out_dir = tempdir().unwrap(); - let refused = one_guest(&path, PAGE, out_dir.path()).unwrap_err(); - assert!(matches!( - refused, - ModulesError::GuestMemory { - needed: TWO_PAGES, - budget: PAGE - } - )); - let _fits = one_guest(&path, TWO_PAGES, out_dir.path()).unwrap(); + // A component's memory, and a core module's, whose table is not counted, as its interpreter keeps that itself. + for text in [ + "(component (core module (memory 2)))", + "(module (memory 2) (table 4 funcref))", + ] { + let (_root, path) = component_module(text); + let out_dir = tempdir().unwrap(); + let refused = one_guest(&path, PAGE, out_dir.path()).unwrap_err(); + assert!(matches!( + refused, + ModulesError::GuestMemory { + needed: TWO_PAGES, + budget: PAGE + } + )); + let _fits = one_guest(&path, TWO_PAGES, out_dir.path()).unwrap(); + } } #[test] diff --git a/services/ws-server-firmware/Cargo.toml b/services/ws-server-firmware/Cargo.toml index a30691ca..ac3fa82c 100644 --- a/services/ws-server-firmware/Cargo.toml +++ b/services/ws-server-firmware/Cargo.toml @@ -38,7 +38,7 @@ edge-toolkit.workspace = true et-modules-service = { workspace = true, features = ["board"] } et-storage-service = { workspace = true, features = ["board"] } et-ws-service = { workspace = true, features = ["board"] } -et-ws-wasi-runner = { workspace = true, features = ["board"] } +et-ws-wasi-runner = { workspace = true, features = ["board", "core-modules"] } picoserve = { workspace = true, features = ["json"] } serde_json = { workspace = true, features = ["alloc"] } diff --git a/services/ws-server-firmware/README.md b/services/ws-server-firmware/README.md index 3972be48..75a08724 100644 --- a/services/ws-server-firmware/README.md +++ b/services/ws-server-firmware/README.md @@ -38,10 +38,11 @@ It serves no modules unless told which to build in, through the variables the `s - `MODULES_ELSEWHERE` and `MODULES_ELSEWHERE_URL`: module names too big for a board's 4 MiB of flash, listed with the rest at `/modules/`, and the base URL a request for one of their files is redirected to. - `RUNNER_GUESTS`: the runners to start beside the hub, comma-separated `runner=package` pairs, each naming a module - `MODULES_PATHS` builds in whose `main` is a WebAssembly component. The build compiles each for the board, and - refuses one that imports what a board does not host or guests needing more memory than the board holds for them, - which is 6 MiB of the Pico Plus 2 W's PSRAM and none on the other boards. The guests start with what they declare, - and the same 6 MiB bounds what they grow to as they run. + `MODULES_PATHS` builds in whose `main` is a WebAssembly component or a Wasm 1.0 core module. The build compiles a + component for the board, and builds a core module in as it is, for the board's interpreter. It refuses one that + imports what a board does not host or guests needing more memory than the board holds for them, which is 6 MiB of + the Pico Plus 2 W's PSRAM and none on the other boards. The guests start with what they declare, and the same 6 MiB + bounds what they grow to as they run. `et-cli generate-deployment --output-type firmware-pico2`, `firmware-esp32c6` or `firmware-pico-plus2` writes the tasks that set all of these for a scenario. diff --git a/services/ws-server-firmware/build.rs b/services/ws-server-firmware/build.rs index 5c8cd12b..117efad1 100644 --- a/services/ws-server-firmware/build.rs +++ b/services/ws-server-firmware/build.rs @@ -10,10 +10,11 @@ use std::path::{Path, PathBuf}; use et_firmware_modules::{BOARD_VARIABLES, RUNNER_GUESTS, board, generate, guests}; use fs_err as fs; -/// The bytes a board with PSRAM holds for its guests' memories: its 8 MiB, less what wasmtime keeps beside them. +/// The bytes a board with PSRAM holds for its guests' memories: its 8 MiB, less what the engines keep beside them. /// -/// That is each guest's compiled code, copied in when it loads, its 64 KiB stack, and the store and instance, all of -/// which share the one heap with the hub's 160 KiB. +/// That is each guest's code, a component's compiled code copied in when it loads or the code `SpaceWasm` decodes a +/// core module to, its 64 KiB stack, and its engine's own state, all of which share the one heap with the hub's 160 +/// KiB. const PSRAM_GUEST_MEMORY: u64 = 6 * 1024 * 1024; /// The flash `memory.x` gives the Pico 2 W, which the Pico Plus 2 W's build replaces with its own. const PICO2W_FLASH: &str = "FLASH : ORIGIN = 0x10000000, LENGTH = 4096K"; diff --git a/services/ws-server-firmware/src/board/hub.rs b/services/ws-server-firmware/src/board/hub.rs index b9ddc667..e179c1cd 100644 --- a/services/ws-server-firmware/src/board/hub.rs +++ b/services/ws-server-firmware/src/board/hub.rs @@ -20,12 +20,13 @@ use embassy_net::{Stack, StackResources}; use embassy_time::Instant; use et_ws_service::hub::Stamps; use et_ws_service::picoserve_hub::{self, Hub}; +use et_ws_wasi_runner::board::run_guest; use picoserve::time::EmbassyTimer; use static_cell::StaticCell; use super::platform::{BoardPlatform, spawn_runner}; use crate::local::BoardStorage; -use crate::runner::{GUEST_POOL, GUESTS, Shared, run_guest}; +use crate::runner::{GUEST_POOL, GUESTS, Shared}; use crate::server::{Served, serve as serve_connection}; use crate::{HTTP_BUFFER, MAX_FRAME_SIZE, MAX_QUEUED, MODULES, PORT}; diff --git a/services/ws-server-firmware/src/board/remote.rs b/services/ws-server-firmware/src/board/remote.rs index 28599dca..e8fb5fcf 100644 --- a/services/ws-server-firmware/src/board/remote.rs +++ b/services/ws-server-firmware/src/board/remote.rs @@ -17,10 +17,11 @@ use embassy_net::tcp::{Error, TcpReader, TcpSocket, TcpWriter}; use embassy_net::{IpEndpoint, Stack, StackResources}; use embedded_io_async::{ErrorType, Read, Write}; use et_ws_wasi_runner::board::net::{Connector, NetError, NetLink, NetTransport, Split, drive}; +use et_ws_wasi_runner::board::run_guest; use super::platform::{BoardPlatform, spawn_runner}; use crate::HUB_ADDRESS; -use crate::runner::{GUEST_POOL, GUESTS, Shared, run_guest}; +use crate::runner::{GUEST_POOL, GUESTS, Shared}; /// Each socket's TCP receive and transmit buffer. const TCP_BUFFER: usize = 4 * 1024; diff --git a/services/ws-server-firmware/src/host.rs b/services/ws-server-firmware/src/host.rs index 746f080e..5a59d1b1 100644 --- a/services/ws-server-firmware/src/host.rs +++ b/services/ws-server-firmware/src/host.rs @@ -2,8 +2,8 @@ //! //! The hub, its settings, the modules built in and the guests its runners run are the board's own; only the network //! stack, the executor and the clock differ. It listens on the address its one argument names, or on the board's port -//! on every interface, and says where on its first line of output, so a caller can ask for port 0 and learn the one -//! it got. A build for a hub elsewhere serves nothing, and runs its guests against that hub as the board would. +//! on every interface, and says where on its first line of output, so a caller can ask for port 0 and learn the one it +//! got. A build for a hub elsewhere serves nothing, and runs its guests against that hub as the board would. use std::error::Error; use std::io::Write as _; @@ -29,7 +29,7 @@ use et_ws_wasi_runner::bindings::wasi::logging::logging::Level; use et_ws_wasi_runner::board::net::{NetLink, NetTransport, drive}; #[cfg(remote_hub)] use et_ws_wasi_runner::board::net_tokio::TokioConnector; -use et_ws_wasi_runner::board::{Console, Platform}; +use et_ws_wasi_runner::board::{Console, Platform, run_guest}; #[cfg(not(remote_hub))] use picoserve::time::TokioTimer; #[cfg(not(remote_hub))] @@ -40,7 +40,7 @@ use tokio::task::LocalSet; use crate::HUB_ADDRESS; #[cfg(not(remote_hub))] use crate::local::BoardStorage; -use crate::runner::{GUESTS, Shared, run_guest}; +use crate::runner::{GUESTS, Shared}; #[cfg(not(remote_hub))] use crate::server::{Served, serve}; #[cfg(not(remote_hub))] diff --git a/services/ws-server-firmware/src/main.rs b/services/ws-server-firmware/src/main.rs index 0de58778..b3feafc3 100644 --- a/services/ws-server-firmware/src/main.rs +++ b/services/ws-server-firmware/src/main.rs @@ -10,6 +10,7 @@ #![cfg_attr(target_os = "none", no_std, no_main)] #![recursion_limit = "256"] +#[cfg(target_os = "none")] extern crate alloc; #[cfg(not(remote_hub))] diff --git a/services/ws-server-firmware/src/runner.rs b/services/ws-server-firmware/src/runner.rs index f5fe235c..f7012691 100644 --- a/services/ws-server-firmware/src/runner.rs +++ b/services/ws-server-firmware/src/runner.rs @@ -1,10 +1,10 @@ -//! The wasi runners the firmware starts: each guest the build compiled in, run on the board's engine. +//! What the wasi runners the firmware starts share: the guests the build built in, the memory they draw on, and the +//! hub's storage when the hub is beside them. //! -//! Each reaches its hub through the [`Transport`] it is handed: the hub beside it, or one on another machine. The board -//! and the host share all of it but the clock and the log, which each hands in as a [`Platform`]. +//! Each runs through `et_ws_wasi_runner::board::run_guest`, reaching its hub through the transport it is handed: the +//! hub beside it, or one on another machine. The board and the host share all of it but the clock and the log, which +//! each hands in as a platform. -#[cfg(target_os = "none")] -use alloc::string::ToString as _; use core::ptr::null_mut; use core::sync::atomic::{AtomicPtr, Ordering}; @@ -12,8 +12,7 @@ use core::sync::atomic::{AtomicPtr, Ordering}; use et_ws_service::picoserve_hub::Hub; #[cfg(not(remote_hub))] use et_ws_wasi_runner::board::Buckets; -use et_ws_wasi_runner::board::{BoardError, MemoryBudget, Platform, Transport, run}; -use et_ws_wasi_runner::engine::board_engine; +use et_ws_wasi_runner::board::MemoryBudget; #[cfg(not(remote_hub))] use crate::local::{BoardStorage, HubTransport}; @@ -29,9 +28,6 @@ include!(concat!(env!("OUT_DIR"), "/guests.rs")); )] pub(crate) const GUEST_POOL: usize = if GUESTS.is_empty() { 1 } else { GUESTS.len() }; -/// How long, in nanoseconds of the board's clock, a guest whose run failed waits before it is run again. -const RERUN_DELAY: u64 = 5_000_000_000; - /// The pointers wasmtime keeps for the call it is in, which with one thread are statics rather than a thread's. /// /// Slot 0 is its runtime state, and slot 1 its component-model-async state; it asks for no other. @@ -102,51 +98,3 @@ impl Shared { HubTransport::new(hub, platform, self.buckets.clone()) } } - -/// Run the guest `name` on `platform` until it finishes. -/// -/// It reaches its hub through a transport from `transport`, and keeps its memories in `memory`. `component` is the -/// guest as the build compiled it in. A run the guest itself fails, as it does when its hub drops the connection under -/// it, is run again on a fresh transport after [`RERUN_DELAY`], as a supervisor restarts a runner process; one that -/// cannot load the guest is not, since it would fail the same way every time. How each run ended is logged rather than -/// returned, since nothing waits on a runner: the hub carries on without it. -#[expect( - clippy::single_call_fn, - reason = "shared by the board's guest task and the host's, each of which a build has only one of" -)] -pub(crate) async fn run_guest( - name: &str, - component: &[u8], - platform: P, - mut transport: impl FnMut() -> T, - memory: MemoryBudget, -) where - P: Platform + Clone + 'static, - T: Transport + 'static, -{ - let engine = match board_engine() { - Ok(engine) => engine, - Err(error) => return report(&platform, name, &BoardError::Wasmtime(error).to_string()), - }; - loop { - match run(&engine, component, platform.clone(), transport(), memory.clone()).await { - Ok(()) => return report(&platform, name, "finished"), - Err(error @ BoardError::Guest(_)) => { - report(&platform, name, &alloc::format!("{error}; running it again")); - platform.sleep_until(platform.now().saturating_add(RERUN_DELAY)).await; - } - Err(error) => return report(&platform, name, &error.to_string()), - } - } -} - -/// Log how a run of the guest `name` ended, on `platform`'s stderr. -fn report

(platform: &P, name: &str, ended: &str) -where - P: Platform, -{ - platform.write( - et_ws_wasi_runner::board::Console::Stderr, - alloc::format!("runner {name}: {ended}\n").as_bytes(), - ); -} diff --git a/services/ws-wasi-runner/README.md b/services/ws-wasi-runner/README.md index 532e9176..4af2573f 100644 --- a/services/ws-wasi-runner/README.md +++ b/services/ws-wasi-runner/README.md @@ -53,8 +53,8 @@ operations as the world -- the websocket, the storage buckets and the log -- fla An import that has to wait on the hub pauses the interpreter. The call is carried out on the same `HostState` a component's imports are, and the guest resumed with its result; between pauses the interpreter runs in slices of instructions, yielding between them. SpaceWasm's engine cannot move between threads, so the module runs on a thread -of its own. The `core_module` module is `no_std` + `alloc` and runs on any `CoreHost`, so a board could host a core -module too, though `board::run` runs only components so far. +of its own. The `core_module` module is `no_std` + `alloc` and runs on any `CoreHost`, which is how a board hosts a +core module too. ## On a board @@ -72,6 +72,12 @@ Preview 2 it hosts only what the ws-module guests import -- their standard streams, an empty environment, the monotonic clock and polling -- since `wasmtime-wasi` needs `std`; `wasi:nn` and `wasi:webgpu` are host-only. +With `core-modules`, `board::run_core_module` runs a core module on SpaceWasm with its `et` imports hosted on the same +`Platform` and `Transport`. SpaceWasm decodes the module as it is, so a firmware builds one in with nothing compiled +ahead of time. Its linear memory draws on the `MemoryBudget` as a component's memories do. `board::run_guest` is what +a firmware runs each guest through: it picks the engine by the guest's header, as the host runner does, and supervises +the guest as a process manager would a runner. + wasmtime asks the firmware for one thing more, two thread-local pointers through `wasmtime_tls_get` and `wasmtime_tls_set`, which on a single core are statics. A guest's linear memory is its declared size, about 1 MiB for the Rust guests, so a board needs external RAM such as PSRAM to hold it: et-ws-server-firmware runs guests on the diff --git a/services/ws-wasi-runner/src/board/host.rs b/services/ws-wasi-runner/src/board/host.rs index 89b808cf..db040295 100644 --- a/services/ws-wasi-runner/src/board/host.rs +++ b/services/ws-wasi-runner/src/board/host.rs @@ -1,5 +1,5 @@ -//! A board's store data, and its hosting of the `runner` world: the guest's log through the board, and its websocket -//! and storage buckets the hub's, over the board's transport. +//! A board's store data, and its hosting of the `runner` world and of a core module's `et` imports: the guest's log +//! through the board, and its websocket and storage buckets the hub's, over the board's transport. use alloc::collections::VecDeque; #[cfg(not(feature = "std"))] @@ -20,7 +20,11 @@ use crate::bindings::et::ws_wasi::ws::{State, WsError}; use crate::bindings::wasi::keyvalue::store::{Error as StoreError, KeyResponse}; use crate::bindings::wasi::logging::logging::Level; use crate::bindings::{et, wasi}; +#[cfg(feature = "core-modules")] +use crate::convert::frame_of; use crate::convert::{Frame, client_frame, server_message, server_message_to_wit}; +#[cfg(feature = "core-modules")] +use crate::core_module::CoreHost; use crate::keyvalue::{Bucket, bucket_from_identifier, kv_not_implemented}; /// How long `connect` waits for each frame while it waits for the hub's `et-connect-ack`. @@ -49,7 +53,7 @@ impl BoardHost { /// /// The guest reaches the hub through `transport`, and keeps its memories in `memory`. #[cfg_attr( - not(test), + not(any(test, feature = "core-modules")), expect( clippy::single_call_fn, reason = "the store data's constructor, called where run makes the store" @@ -93,6 +97,27 @@ impl BoardHost { } impl BoardHost { + /// Send `frame` over the websocket, which the caller has checked is open. + async fn send_frame(&mut self, frame: Frame) -> Result<(), WsError> { + match self.transport.send(frame).await { + Ok(()) => Ok(()), + Err(reason) => Err(WsError::Transport(reason.to_string())), + } + } + + /// The next message from the hub: one that arrived while connecting, or the next frame within `timeout_ms`. + async fn next_message(&mut self, timeout_ms: u32) -> Result, WsError> { + self.connected()?; + if let Some(message) = self.inbox.pop_front() { + return Ok(Some(message)); + } + match self.transport.recv(timeout_ms).await { + Ok(Some(frame)) => Ok(Some(server_message(frame)?)), + Ok(None) => Ok(None), + Err(reason) => Err(WsError::Transport(reason.to_string())), + } + } + /// Send the hello over the open transport and wait for the hub's ack, queueing it and anything ahead of it. async fn register(&mut self) -> Result<(), WsError> { let hello = serde_json::to_string(&ClientMessage::Connect { agent_id: None })?; @@ -117,6 +142,50 @@ impl BoardHost { } } +/// What `bucket` holds under `key` on the hub `transport` reaches. +/// +/// A module's bucket holds nothing, since a board serves no module's files. +#[cfg_attr( + not(feature = "core-modules"), + expect( + clippy::single_call_fn, + reason = "shared with a core module's kv_get when the crate hosts one" + ) +)] +async fn load(transport: &mut T, bucket: &Bucket, key: &str) -> Result>, StoreError> +where + T: Transport, +{ + let Bucket::Storage { agent_id } = bucket else { + return Ok(None); + }; + match transport.load(agent_id, key).await { + Ok(value) => Ok(value), + Err(error) => Err(StoreError::Other(error.to_string())), + } +} + +/// Store `value` under `key` in `bucket` on the hub `transport` reaches, which refuses a module's bucket. +#[cfg_attr( + not(feature = "core-modules"), + expect( + clippy::single_call_fn, + reason = "shared with a core module's kv_set when the crate hosts one" + ) +)] +async fn store(transport: &mut T, bucket: &Bucket, key: &str, value: Vec) -> Result<(), StoreError> +where + T: Transport, +{ + let Bucket::Storage { agent_id } = bucket else { + return Err(StoreError::AccessDenied); + }; + match transport.store(agent_id, key, value).await { + Ok(()) => Ok(()), + Err(error) => Err(StoreError::Other(error.to_string())), + } +} + impl wasi::logging::logging::Host for BoardHost { async fn log(&mut self, level: Level, context: String, message: String) { self.platform.log(level, &context, &message); @@ -136,24 +205,11 @@ impl wasi::keyvalue::store::Host for BoardHost impl wasi::keyvalue::store::HostBucket for BoardHost { async fn get(&mut self, self_: Resource, key: String) -> Result>, StoreError> { - let Bucket::Storage { agent_id } = self.table.get(&self_)? else { - // A board serves no module's files, so none of them holds a key. - return Ok(None); - }; - match self.transport.load(agent_id, &key).await { - Ok(value) => Ok(value), - Err(error) => Err(StoreError::Other(error.to_string())), - } + load(&mut self.transport, self.table.get(&self_)?, &key).await } async fn set(&mut self, self_: Resource, key: String, value: Vec) -> Result<(), StoreError> { - let Bucket::Storage { agent_id } = self.table.get(&self_)? else { - return Err(StoreError::AccessDenied); - }; - match self.transport.store(agent_id, &key, value).await { - Ok(()) => Ok(()), - Err(error) => Err(StoreError::Other(error.to_string())), - } + store(&mut self.transport, self.table.get(&self_)?, &key, value).await } #[expect( @@ -232,23 +288,14 @@ impl et::ws_wasi::ws::Host for BoardHost { async fn send(&mut self, message: WitClientMessage) -> Result<(), WsError> { self.connected()?; - match self.transport.send(client_frame(message)?).await { - Ok(()) => Ok(()), - Err(reason) => Err(WsError::Transport(reason.to_string())), - } + self.send_frame(client_frame(message)?).await } async fn recv(&mut self, timeout_ms: u32) -> Result, WsError> { - self.connected()?; - let message = match self.inbox.pop_front() { - Some(message) => message, - None => match self.transport.recv(timeout_ms).await { - Ok(Some(frame)) => server_message(frame)?, - Ok(None) => return Ok(None), - Err(reason) => return Err(WsError::Transport(reason.to_string())), - }, - }; - Ok(Some(server_message_to_wit(message)?)) + self.next_message(timeout_ms) + .await? + .map(server_message_to_wit) + .transpose() } async fn disconnect(&mut self) { @@ -260,3 +307,47 @@ impl et::ws_wasi::ws::Host for BoardHost { self.agent_id.clear(); } } + +#[cfg(feature = "core-modules")] +impl CoreHost for BoardHost { + /// Open the websocket and register, as a component's `connect` does. + async fn connect(&mut self) -> Result<(), WsError> { + et::ws_wasi::ws::Host::connect(self).await + } + + /// The agent id, as a component's `agent-id` gives it. + async fn agent_id(&mut self) -> String { + et::ws_wasi::ws::Host::agent_id(self).await + } + + /// Send `message` as the frame it goes out as. + async fn send(&mut self, message: ClientMessage) -> Result<(), WsError> { + self.connected()?; + self.send_frame(frame_of(message)?).await + } + + /// The next message, from the same inbox a component's `recv` drains. + async fn recv(&mut self, timeout_ms: u32) -> Result, WsError> { + self.next_message(timeout_ms).await + } + + /// Close the websocket and forget what arrived on it, as a component's `disconnect` does. + async fn disconnect(&mut self) { + et::ws_wasi::ws::Host::disconnect(self).await; + } + + /// What the hub stores under `key` in the bucket `bucket` names, reached over the board's transport. + async fn get(&mut self, bucket: &str, key: &str) -> Result>, StoreError> { + load(&mut self.transport, &bucket_from_identifier(bucket)?, key).await + } + + /// Have the hub store `value` under `key` in the bucket `bucket` names, over the board's transport. + async fn set(&mut self, bucket: &str, key: &str, value: Vec) -> Result<(), StoreError> { + store(&mut self.transport, &bucket_from_identifier(bucket)?, key, value).await + } + + /// Record the entry through the board, as a component's `wasi:logging` call is. + async fn log(&mut self, level: Level, context: &str, message: &str) { + self.platform.log(level, context, message); + } +} diff --git a/services/ws-wasi-runner/src/board/memory.rs b/services/ws-wasi-runner/src/board/memory.rs index ee8a4893..122d4d52 100644 --- a/services/ws-wasi-runner/src/board/memory.rs +++ b/services/ws-wasi-runner/src/board/memory.rs @@ -1,10 +1,19 @@ //! What a board holds for its guests' memories, and each guest's draw on it as its memories and tables grow. use alloc::sync::Arc; +#[cfg(feature = "core-modules")] +use core::alloc::Layout; +#[cfg(feature = "core-modules")] +use core::ptr::NonNull; use core::sync::atomic::{AtomicUsize, Ordering}; +#[cfg(feature = "core-modules")] +use spacewasm::{AllocError, WasmMemoryAllocator}; use wasmtime::ResourceLimiter; +#[cfg(feature = "core-modules")] +use crate::core_module::LinearMemory; + /// The bytes a board holds for its guests' memories and tables, which every guest it runs draws on as they grow. /// /// A handle: its clones are the same bytes, so every guest a board runs is handed one. A guest whose growth would spend @@ -112,3 +121,54 @@ impl Drop for Allowance { self.budget.give_back(self.held); } } + +/// A core module's linear memory, drawn from its board's [`MemoryBudget`] as it is allocated and as it grows. +/// +/// A module starting with more than is left fails to load, and growth past what is left fails in the guest, as +/// `memory.grow` returning -1. What the memory held is returned when `SpaceWasm` frees it, as the module ends. +#[cfg(feature = "core-modules")] +pub(super) struct CoreMemory(MemoryBudget); + +#[cfg(feature = "core-modules")] +impl CoreMemory { + /// A core module's memory, drawing on `budget`. + #[expect( + clippy::single_call_fn, + reason = "the memory's constructor, called where a core module is run" + )] + pub(super) const fn new(budget: MemoryBudget) -> Self { + Self(budget) + } + + /// What `allocate` returns, once `bytes` are drawn from the budget; nothing stays drawn when either fails. + fn drawing(&self, bytes: usize, allocate: A) -> Result, AllocError> + where + A: FnOnce() -> Result, AllocError>, + { + if !self.0.take(bytes) { + return Err(AllocError::OutOfMemory); + } + // Only the board's heap running out fails `allocate` after the budget allowed it, which a test cannot cause. + allocate().inspect_err(|_| self.0.give_back(bytes)) + } +} + +#[cfg(feature = "core-modules")] +impl WasmMemoryAllocator for CoreMemory { + /// A new memory of `layout`, drawing its whole size. + fn allocate(&self, layout: Layout) -> Result, AllocError> { + self.drawing(layout.size(), || LinearMemory.allocate(layout)) + } + + /// The memory at `ptr` grown from `old_layout` to `layout`, drawing what it grew by. + fn reallocate(&self, ptr: NonNull, old_layout: Layout, layout: Layout) -> Result, AllocError> { + let grown = layout.size().saturating_sub(old_layout.size()); + self.drawing(grown, || LinearMemory.reallocate(ptr, old_layout, layout)) + } + + /// Free the memory at `ptr`, returning its whole size. + fn deallocate(&self, ptr: NonNull, layout: Layout) { + LinearMemory.deallocate(ptr, layout); + self.0.give_back(layout.size()); + } +} diff --git a/services/ws-wasi-runner/src/board/mod.rs b/services/ws-wasi-runner/src/board/mod.rs index 94576a6f..c186cf42 100644 --- a/services/ws-wasi-runner/src/board/mod.rs +++ b/services/ws-wasi-runner/src/board/mod.rs @@ -1,7 +1,8 @@ //! The runner for a board with no operating system. //! //! It runs a component compiled ahead of time for the board's engine, with the `runner` world and the WASI Preview 2 -//! its guest imports hosted on what the board provides. +//! its guest imports hosted on what the board provides, and with the `core-modules` feature a Wasm 1.0 core module on +//! `SpaceWasm`, with the `et` imports hosted on the same. mod buckets; #[cfg(feature = "coverage")] @@ -13,6 +14,9 @@ pub mod net; pub mod net_tokio; mod platform; mod run; +#[cfg(feature = "core-modules")] +mod run_core; +mod run_guest; #[cfg(test)] mod test_host; mod wasi; @@ -22,3 +26,6 @@ pub use self::host::BoardHost; pub use self::memory::MemoryBudget; pub use self::platform::{Console, Platform, Transport}; pub use self::run::{BoardError, run}; +#[cfg(feature = "core-modules")] +pub use self::run_core::run_core_module; +pub use self::run_guest::{RERUN_DELAY, run_guest}; diff --git a/services/ws-wasi-runner/src/board/run.rs b/services/ws-wasi-runner/src/board/run.rs index 45966a9c..0841d185 100644 --- a/services/ws-wasi-runner/src/board/run.rs +++ b/services/ws-wasi-runner/src/board/run.rs @@ -20,6 +20,10 @@ pub enum BoardError { /// The guest's `entry.run` returned an error. #[error("the guest failed: {0:?}")] Guest(EntryError), + /// The core module did not run to completion, its own `run` returning a failure among the reasons. + #[cfg(feature = "core-modules")] + #[error("{0}")] + CoreModule(crate::core_module::CoreError), } impl From for BoardError { @@ -28,6 +32,13 @@ impl From for BoardError { } } +#[cfg(feature = "core-modules")] +impl From for BoardError { + fn from(err: crate::core_module::CoreError) -> Self { + Self::CoreModule(err) + } +} + /// Run `component` on `engine` until its `entry.run` returns, hosting it on what the board hands it. /// /// `engine` is [`crate::engine::board_engine`]'s, and `component` what `engine::precompile` compiled for it: a firmware diff --git a/services/ws-wasi-runner/src/board/run_core.rs b/services/ws-wasi-runner/src/board/run_core.rs new file mode 100644 index 00000000..2d725088 --- /dev/null +++ b/services/ws-wasi-runner/src/board/run_core.rs @@ -0,0 +1,39 @@ +//! Run a Wasm 1.0 core module on a board, on `SpaceWasm`, with its `et` imports hosted by the board. + +use super::host::BoardHost; +use super::memory::{CoreMemory, MemoryBudget}; +use super::platform::{Platform, Transport}; +use super::run::BoardError; +use crate::core_module::run_within; + +/// The interpreter's operand and call stack on a board, in its four-byte words: 64 KiB, as wasmtime's stack is there. +const STACK_WORDS: usize = 0x4000; + +/// Run the core module `module` until its `run` returns, hosting its `et` imports on what the board hands it. +/// +/// `SpaceWasm` decodes a core module as it is, so a firmware builds one in as its package holds it, with nothing +/// compiled ahead of time. The guest runs on `platform` and reaches the hub and its storage through `transport`, as a +/// component does. Its linear memory draws on `memory` as a component's memories do; the interpreter's stack and the +/// code it decodes the module to are kept beside it, as wasmtime keeps a component's. +/// +/// # Errors +/// +/// When the module does not decode, validate or link, imports anything but the `et` functions, starts with more memory +/// than `memory` has left, traps, or its `run` returns a failure. +#[expect( + clippy::future_not_send, + reason = "SpaceWasm's engine is neither Send nor Sync, and a board runs each guest's task on its one core" +)] +pub async fn run_core_module( + module: &[u8], + platform: P, + transport: T, + memory: MemoryBudget, +) -> Result<(), BoardError> +where + P: Platform, + T: Transport, +{ + let host = BoardHost::new(platform, transport, memory.clone()); + Ok(run_within(module, host, CoreMemory::new(memory), STACK_WORDS).await?) +} diff --git a/services/ws-wasi-runner/src/board/run_guest.rs b/services/ws-wasi-runner/src/board/run_guest.rs new file mode 100644 index 00000000..33e718ed --- /dev/null +++ b/services/ws-wasi-runner/src/board/run_guest.rs @@ -0,0 +1,99 @@ +//! Run a board's guest, of whichever kind the build built in, until it finishes, as a supervisor runs a runner process. + +use core::fmt::Display; + +use super::memory::MemoryBudget; +use super::platform::{Console, Platform, Transport}; +use super::run::{BoardError, run}; +#[cfg(feature = "core-modules")] +use super::run_core::run_core_module; +#[cfg(feature = "core-modules")] +use crate::core_module::{CoreError, is_core_module}; +use crate::engine::board_engine; + +/// How long, in nanoseconds of the board's clock, a guest whose run failed waits before it is run again. +pub const RERUN_DELAY: u64 = 5_000_000_000; + +/// Run the guest `name` on `platform` until it finishes, writing how each run ended to `platform`'s stderr. +/// +/// `guest` is the guest as a firmware builds it in: a component compiled for [`board_engine`], which runs on that, +/// or with the `core-modules` feature a core module as its package holds it, which runs on the board's interpreter. +/// It reaches its hub through a transport from `transport`, and keeps its memories in `memory`. A run the guest +/// itself fails, as it does when its hub drops the connection under it, is run again on a fresh transport after +/// [`RERUN_DELAY`], as a supervisor restarts a runner process; one that cannot load the guest, or that the guest traps +/// in, is not, since it would fail the same way every time. How each run ended is written rather than returned, since +/// nothing waits on a runner: the hub carries on without it. +#[cfg_attr( + feature = "core-modules", + expect( + clippy::future_not_send, + reason = "SpaceWasm's engine is neither Send nor Sync, and a board runs each guest's task on its one core" + ) +)] +pub async fn run_guest(name: &str, guest: &[u8], platform: P, mut transport: N, memory: MemoryBudget) +where + P: Platform + Clone + 'static, + T: Transport + 'static, + N: FnMut() -> T, +{ + #[cfg(feature = "core-modules")] + if is_core_module(guest) { + let run_once = || run_core_module(guest, platform.clone(), transport(), memory.clone()); + return rerunning(&platform, name, run_once).await; + } + let engine = match board_engine() { + Ok(engine) => engine, + Err(error) => return report(&platform, name, BoardError::Wasmtime(error)), + }; + let run_once = || run(&engine, guest, platform.clone(), transport(), memory.clone()); + rerunning(&platform, name, run_once).await; +} + +/// Run the guest `name` through `run_once` until a run does not fail in the guest itself, reporting how each ended. +#[cfg_attr( + not(feature = "core-modules"), + expect( + clippy::single_call_fn, + reason = "the supervision of run_guest, which runs a core module through it too when the crate has them" + ) +)] +async fn rerunning(platform: &P, name: &str, mut run_once: R) +where + P: Platform, + R: FnMut() -> F, + F: Future>, +{ + loop { + match run_once().await { + Ok(()) => return report(platform, name, "finished"), + Err(error) if failed_in_guest(&error) => { + report(platform, name, format_args!("{error}; running it again")); + platform.sleep_until(platform.now().saturating_add(RERUN_DELAY)).await; + } + Err(error) => return report(platform, name, error), + } + } +} + +/// Whether `error` is the guest's own failure, which a run on a fresh transport need not repeat. +#[expect( + clippy::single_call_fn, + reason = "the rerun decision of rerunning, named for the one question it answers" +)] +const fn failed_in_guest(error: &BoardError) -> bool { + match error { + BoardError::Guest(_) => true, + #[cfg(feature = "core-modules")] + BoardError::CoreModule(error) => matches!(error, CoreError::Guest(_)), + BoardError::Wasmtime(_) => false, + } +} + +/// Write how a run of the guest `name` ended to `platform`'s stderr. +fn report(platform: &P, name: &str, ended: E) +where + P: Platform, + E: Display, +{ + platform.write(Console::Stderr, alloc::format!("runner {name}: {ended}\n").as_bytes()); +} diff --git a/services/ws-wasi-runner/src/core_module/allocator.rs b/services/ws-wasi-runner/src/core_module/allocator.rs index bc5b3b68..5f69c961 100644 --- a/services/ws-wasi-runner/src/core_module/allocator.rs +++ b/services/ws-wasi-runner/src/core_module/allocator.rs @@ -65,7 +65,7 @@ pub unsafe extern "C" fn __spacewasm_dealloc(ptr: *mut u8, size: usize, align: u /// /// `SpaceWasm` zeroes each page it hands the guest, and never asks for a zero-sized memory: one of no pages is never /// allocated, and a grow from it allocates rather than reallocating. -pub(super) struct LinearMemory; +pub(crate) struct LinearMemory; impl WasmMemoryAllocator for LinearMemory { /// A new linear memory of `layout`, or why there is none. diff --git a/services/ws-wasi-runner/src/core_module/header.rs b/services/ws-wasi-runner/src/core_module/header.rs new file mode 100644 index 00000000..059fcfe8 --- /dev/null +++ b/services/ws-wasi-runner/src/core_module/header.rs @@ -0,0 +1,13 @@ +//! Telling a core module from other bytes by its header. + +/// The first bytes of every Wasm 1.0 core module: WebAssembly's magic number, then version 1. +const HEADER: [u8; 8] = *b"\0asm\x01\0\0\0"; + +/// Whether `bytes` are a core module, as their header says. +/// +/// A component's header carries another version, and what wasmtime compiles a component to is no WebAssembly at all, so +/// this tells a core module from either. +#[must_use] +pub fn is_core_module(bytes: &[u8]) -> bool { + bytes.starts_with(&HEADER) +} diff --git a/services/ws-wasi-runner/src/core_module/mod.rs b/services/ws-wasi-runner/src/core_module/mod.rs index df2e4fcb..58a2f459 100644 --- a/services/ws-wasi-runner/src/core_module/mod.rs +++ b/services/ws-wasi-runner/src/core_module/mod.rs @@ -1,8 +1,8 @@ //! Run a Wasm 1.0 core module on NASA's `SpaceWasm` interpreter, hosting the `et` imports `et-core-guest` defines. //! //! A component runs on wasmtime. A core module cannot import the `et:ws-wasi` world, so it runs here instead, its -//! imports carried out on whatever [`CoreHost`] the caller hands [`run()`]. An import that waits on the hub pauses the -//! interpreter, and [`run()`] awaits it and resumes the guest; otherwise the interpreter runs in slices of +//! imports carried out on whatever [`CoreHost`] the caller hands [`run()`]. An import that waits on the hub pauses +//! the interpreter, and [`run()`] awaits it and resumes the guest; otherwise the interpreter runs in slices of //! instructions, yielding between them, so a guest computing for a long time never keeps its executor from everything //! else. //! @@ -10,11 +10,17 @@ mod allocator; mod error; +mod header; mod host; mod imports; mod run; mod stream; +#[cfg(feature = "board")] +pub(crate) use self::allocator::LinearMemory; pub use self::error::CoreError; +pub use self::header::is_core_module; pub use self::host::CoreHost; pub use self::run::run; +#[cfg(feature = "board")] +pub(crate) use self::run::run_within; diff --git a/services/ws-wasi-runner/src/core_module/run.rs b/services/ws-wasi-runner/src/core_module/run.rs index 15569c9f..eeba5168 100644 --- a/services/ws-wasi-runner/src/core_module/run.rs +++ b/services/ws-wasi-runner/src/core_module/run.rs @@ -11,7 +11,7 @@ use core::task::{Context, Poll}; use et_core_guest::abi; use spacewasm::{ CodeBuilder, CompilerOptions, Engine, ExportDesc, Interpreter, InterpreterResult, InterpreterRunner as _, Module, - Ref, ValType, WasmRef, + Ref, ValType, WasmMemoryAllocator, WasmRef, }; use super::allocator::LinearMemory; @@ -21,7 +21,7 @@ use super::{CoreError, CoreHost}; /// How many instructions the interpreter runs before yielding to the executor. const FUEL_SLICE: usize = 100_000; -/// The engine's operand and call stack, in words. +/// The engine's operand and call stack on a host, in its four-byte words: 4 MiB. const STACK_WORDS: usize = 0x10_0000; /// The deepest nesting of blocks the decoder validates a function with. const MAX_CONTROL_FRAMES: usize = 256; @@ -41,9 +41,31 @@ const MODULE_NAME: &str = "main"; /// /// When the module does not decode, validate or link, has no `run` of the right type, traps, or its `run` returns /// anything but [`abi::SUCCESS`]. -pub async fn run(wasm: &[u8], mut host: H) -> Result<(), CoreError> +pub async fn run(wasm: &[u8], host: H) -> Result<(), CoreError> where H: CoreHost, +{ + run_within(wasm, host, LinearMemory, STACK_WORDS).await +} + +/// [`run()`], the module's linear memory allocated by `memory`, and the engine's stack `stack_words` words deep. +/// +/// A board bounds both, where a host has room for whatever a module asks. +/// +/// # Errors +/// +/// As [`run()`]'s, and when `memory` cannot allocate what the module starts with, which fails it as it decodes. +#[cfg_attr( + not(feature = "board"), + expect( + clippy::single_call_fn, + reason = "run's body, which a board's runner also calls when the crate has one" + ) +)] +pub(crate) async fn run_within(wasm: &[u8], mut host: H, memory: M, stack_words: usize) -> Result<(), CoreError> +where + H: CoreHost, + M: WasmMemoryAllocator + 'static, { let shared = Shared::default(); let mut code = CodeBuilder::new(CompilerOptions { @@ -51,8 +73,8 @@ where max_backpatch_iterations: None, max_code_pages: MAX_CODE_PAGES, })?; - let mut engine = Engine::new(STACK_WORDS, 1, spacewasm::Vec::from_array([imports::module(&shared)?])?)?; - let memory = spacewasm::Rc::new(LinearMemory)?.into_wasm_memory_allocator(); + let mut engine = Engine::new(stack_words, 1, spacewasm::Vec::from_array([imports::module(&shared)?])?)?; + let memory = spacewasm::Rc::new(memory)?.into_wasm_memory_allocator(); let module = Module::new::( MODULE_NAME, &mut ModuleStream::new(wasm), diff --git a/services/ws-wasi-runner/tests/board.rs b/services/ws-wasi-runner/tests/board.rs index 92160ac4..1c628ca9 100644 --- a/services/ws-wasi-runner/tests/board.rs +++ b/services/ws-wasi-runner/tests/board.rs @@ -1,5 +1,8 @@ -//! Runs guests through the board's runner on this host: compiled ahead of time for the board engine this build has, -//! with a hub played in memory, a clock that jumps to each deadline a guest sleeps until, and a log kept in a vector. +//! Runs guests through the board's runner on this host, with a hub played in memory, a clock that jumps to each +//! deadline a guest sleeps until, and a log kept in a vector. +//! +//! A component is compiled ahead of time for the board engine this build has; with `core-modules`, a core module runs +//! on the board's interpreter as it is. #![cfg(test)] use std::collections::VecDeque; @@ -13,22 +16,94 @@ use edge_toolkit::ws::{ AgentConnectionState, AgentSummary, ClientMessage, ConnectStatus, MessageDeliveryStatus, MessageScope, ServerMessage, }; +#[cfg(feature = "core-modules")] +use et_ws_test_server::math1::{MATH1_INPUT_FILENAME, MATH1_INPUT_JSON, MATH1_OUTPUT_FILENAME, verify_math1_model}; use et_ws_wasi_runner::bindings::et::ws_wasi::ws::WsError; use et_ws_wasi_runner::bindings::exports::et::ws_wasi::entry::EntryError; use et_ws_wasi_runner::bindings::wasi::logging::logging::Level; -use et_ws_wasi_runner::board::{BoardError, Buckets, Console, MemoryBudget, Platform, Transport, run}; +#[cfg(feature = "core-modules")] +use et_ws_wasi_runner::board::run_core_module; +use et_ws_wasi_runner::board::{ + BoardError, Buckets, Console, MemoryBudget, Platform, RERUN_DELAY, Transport, run, run_guest, +}; use et_ws_wasi_runner::convert::Frame; +#[cfg(feature = "core-modules")] +use et_ws_wasi_runner::core_module::CoreError; use et_ws_wasi_runner::engine::{NATIVE_PULLEY, board_engine, precompile}; use rstest::rstest; /// The id the hub assigns the guest. const AGENT_ID: &str = "board-agent"; +/// One WebAssembly page. +#[cfg(feature = "core-modules")] +const PAGE: usize = 64 * 1024; +/// Two pages. +#[cfg(feature = "core-modules")] +const TWO_PAGES: usize = 2 * PAGE; + +/// A core module calling every `et` import that reaches the hub or its storage, returning which step went wrong. +/// +/// It sends before it connects, which is refused, then connects, sends `et-list-agents` and reads the ack and the list. +/// It stores `value` under `key` in its own bucket and reads it back, finds nothing in a module's bucket and is refused +/// a write there, logs `logged`, and disconnects. +#[cfg(feature = "core-modules")] +const REACHES_THE_HUB: &str = r#"(module + (import "et" "connect" (func $connect (result i32))) + (import "et" "send" (func $send (param i32 i32) (result i32))) + (import "et" "recv" (func $recv (param i32) (result i32))) + (import "et" "kv_get" (func $kv_get (param i32 i32 i32 i32) (result i32))) + (import "et" "kv_set" (func $kv_set (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "et" "log" (func $log (param i32 i32 i32 i32 i32))) + (import "et" "disconnect" (func $disconnect)) + (memory 1) + (data (i32.const 0) "{\"type\":\"et-list-agents\"}") + (data (i32.const 32) "board-agent") + (data (i32.const 48) "modules/board") + (data (i32.const 64) "key") + (data (i32.const 72) "value") + (data (i32.const 80) "logged") + (func (export "run") (result i32) + (if (i32.ne (call $send (i32.const 0) (i32.const 25)) (i32.const -3)) (then (return (i32.const 1)))) + (if (i32.ne (call $connect) (i32.const 0)) (then (return (i32.const 2)))) + (if (i32.ne (call $send (i32.const 0) (i32.const 25)) (i32.const 0)) (then (return (i32.const 3)))) + (if (i32.le_s (call $recv (i32.const 0)) (i32.const 0)) (then (return (i32.const 4)))) + (if (i32.le_s (call $recv (i32.const 0)) (i32.const 0)) (then (return (i32.const 5)))) + (if (i32.ne + (call $kv_set (i32.const 32) (i32.const 11) (i32.const 64) (i32.const 3) (i32.const 72) (i32.const 5)) + (i32.const 0)) (then (return (i32.const 6)))) + (if (i32.ne (call $kv_get (i32.const 32) (i32.const 11) (i32.const 64) (i32.const 3)) (i32.const 1)) + (then (return (i32.const 7)))) + (if (i32.ne (call $kv_get (i32.const 48) (i32.const 13) (i32.const 64) (i32.const 3)) (i32.const 0)) + (then (return (i32.const 8)))) + (if (i32.ne + (call $kv_set (i32.const 48) (i32.const 13) (i32.const 64) (i32.const 3) (i32.const 72) (i32.const 5)) + (i32.const -5)) (then (return (i32.const 9)))) + (call $log (i32.const 2) (i32.const 32) (i32.const 11) (i32.const 80) (i32.const 6)) + (call $disconnect) + (i32.const 0)))"#; + +/// A core module that grows its one page of memory by another, returning 0 when the grow fails and 2 when it does not. +#[cfg(feature = "core-modules")] +const GROWS_A_PAGE: &str = r#"(module + (memory 1) + (func (export "run") (result i32) (i32.add (memory.grow (i32.const 1)) (i32.const 1))))"#; + +/// A core module that connects and disconnects, returning 1 when the hub refuses it. +#[cfg(feature = "core-modules")] +const CONNECTS: &str = r#"(module + (import "et" "connect" (func $connect (result i32))) + (import "et" "disconnect" (func $disconnect)) + (func (export "run") (result i32) + (if (i32.ne (call $connect) (i32.const 0)) (then (return (i32.const 1)))) + (call $disconnect) + (i32.const 0)))"#; /// A board's clock and log, played by this host. /// /// The clock only moves when the guest sleeps, and the log keeps every line logged or written, in order. +#[derive(Clone, Default)] struct Host { - clock: AtomicU64, + clock: Arc, lines: Arc>>, } @@ -195,13 +270,31 @@ async fn ran_within( hub: Hub, memory: MemoryBudget, ) -> (Result<(), BoardError>, Vec, Vec, bool) { - let lines = Arc::new(Mutex::new(Vec::new())); - let host = Host { - clock: AtomicU64::new(0), - lines: Arc::clone(&lines), - }; + ran_by(hub, async |host, hub| { + run(&board_engine().unwrap(), component, host, hub, memory).await + }) + .await +} + +/// What `run_guest` left, given the board's clock and log and `hub`. +/// +/// That is how the run ended, every line logged, every frame sent, and whether the websocket was closed. +#[cfg_attr( + not(feature = "core-modules"), + expect( + clippy::single_call_fn, + reason = "shared with the core module cases when the crate has them" + ) +)] +async fn ran_by(hub: Hub, run_guest: R) -> (Result<(), BoardError>, Vec, Vec, bool) +where + R: FnOnce(Host, Hub) -> F, + F: Future>, +{ + let host = Host::default(); + let lines = Arc::clone(&host.lines); let (sent, closed) = (Arc::clone(&hub.sent), Arc::clone(&hub.closed)); - let outcome = run(&board_engine().unwrap(), component, host, hub, memory).await; + let outcome = run_guest(host, hub).await; let lines = lines.lock().unwrap().clone(); let sent = sent.lock().unwrap().clone(); let closed = *closed.lock().unwrap(); @@ -383,11 +476,31 @@ async fn a_guest_runs_within_the_board_s_memory_and_returns_it_when_it_ends() { outcome.unwrap(); assert_eq!(roomy.left(), starts_with.saturating_mul(4)); - let short = MemoryBudget::new(starts_with.saturating_sub(1)); - let (outcome, lines, sent, _) = ran_within(&compiled(module), Hub::default(), short.clone()).await; + let component = compiled(module); + let outcome = ran_short(starts_with, |short| ran_within(&component, Hub::default(), short)).await; assert!(matches!(outcome, Err(BoardError::Wasmtime(_)))); +} + +/// How a guest starting with `starts_with` bytes of memories ended, run by `run_within` on one byte fewer. +/// +/// It must not have run at all, logging and sending nothing, and must have drawn nothing. +#[cfg_attr( + not(feature = "core-modules"), + expect( + clippy::single_call_fn, + reason = "shared with the core module cases when the crate has them" + ) +)] +async fn ran_short(starts_with: usize, run_within: R) -> Result<(), BoardError> +where + R: FnOnce(MemoryBudget) -> F, + F: Future, Vec, Vec, bool)>, +{ + let short = MemoryBudget::new(starts_with.saturating_sub(1)); + let (outcome, lines, sent, _) = run_within(short.clone()).await; assert!(lines.is_empty() && sent.is_empty()); assert_eq!(short.left(), starts_with.saturating_sub(1)); + outcome } #[tokio::test] @@ -396,3 +509,165 @@ async fn bytes_that_were_not_compiled_for_the_board_do_not_load() { assert!(matches!(outcome, Err(BoardError::Wasmtime(_)))); assert!(lines.is_empty() && sent.is_empty()); } + +/// What `run_guest` reported of each run of `guest`, each run's transport the next of `hubs`, and the clock after. +#[cfg_attr( + feature = "core-modules", + expect( + clippy::future_not_send, + reason = "SpaceWasm's engine is not Send, and each case here awaits it on a runtime of its own" + ) +)] +async fn supervised(guest: &[u8], hubs: Vec) -> (Vec, u64) { + let host = Host::default(); + let (lines, clock) = (Arc::clone(&host.lines), Arc::clone(&host.clock)); + let mut hubs = hubs.into_iter(); + run_guest( + "guest", + guest, + host, + move || hubs.next().unwrap(), + MemoryBudget::new(usize::MAX), + ) + .await; + let reports = lines + .lock() + .unwrap() + .iter() + .filter(|line| line.starts_with("runner guest: ")) + .cloned() + .collect(); + (reports, clock.load(Ordering::SeqCst)) +} + +/// Check that `reports` say the guest failed in itself, then finished once run again [`RERUN_DELAY`] later on `clock`. +#[cfg_attr( + not(feature = "core-modules"), + expect( + clippy::single_call_fn, + reason = "shared with the core module case when the crate has them" + ) +)] +fn assert_reran_once(reports: &[String], clock: u64) { + assert!(matches!( + reports, + [failed, finished] if failed.ends_with("; running it again\n") && finished == "runner guest: finished\n" + )); + assert_eq!(clock, RERUN_DELAY); +} + +/// Check that `reports` say the guest's one run ended in a failure it was not run again for, the clock never moving. +#[cfg_attr( + not(feature = "core-modules"), + expect( + clippy::single_call_fn, + reason = "shared with the core module case when the crate has them" + ) +)] +fn assert_not_rerun(reports: &[String], clock: u64) { + assert!(matches!(reports, [ended] if !ended.ends_with("; running it again\n") && !ended.ends_with("finished\n"))); + assert_eq!(clock, 0); +} + +#[tokio::test] +async fn a_component_failing_in_itself_is_run_again_on_a_fresh_transport_until_it_finishes() { + let (reports, clock) = supervised( + &compiled("wasi-data1"), + vec![Hub::new(Fault::Refused, []), Hub::default()], + ) + .await; + assert_reran_once(&reports, clock); +} + +#[tokio::test] +async fn a_component_that_does_not_load_is_not_run_again() { + let (reports, clock) = supervised(b"not a component", vec![Hub::default()]).await; + assert_not_rerun(&reports, clock); +} + +/// What running the core module `module` against `hub` left, its memory drawn from `memory`. +#[cfg(feature = "core-modules")] +#[expect( + clippy::future_not_send, + reason = "SpaceWasm's engine is not Send, and each case here awaits it on a runtime of its own" +)] +async fn ran_core_module( + module: &[u8], + hub: Hub, + memory: MemoryBudget, +) -> (Result<(), BoardError>, Vec, Vec, bool) { + ran_by(hub, async |host, hub| run_core_module(module, host, hub, memory).await).await +} + +#[cfg(feature = "core-modules")] +#[tokio::test] +async fn core_math1_stores_the_model_fitted_to_the_input_its_pointer_names() { + let pointer = format!(r#"{{"type":"math1-input","bucket":"sender","filename":"{MATH1_INPUT_FILENAME}"}}"#); + let hub = Hub::new(Fault::None, [Frame::Text(pointer)]); + let storage = hub.storage.clone(); + storage.set("sender", MATH1_INPUT_FILENAME, MATH1_INPUT_JSON.as_bytes().to_vec()); + let module = component("core-math1"); + let (outcome, lines, _, closed) = ran_core_module(&module, hub, MemoryBudget::new(usize::MAX)).await; + outcome.unwrap(); + assert!(closed && lines.iter().any(|line| line == "workflow complete")); + let stored = storage.get(AGENT_ID, MATH1_OUTPUT_FILENAME).unwrap(); + let model: serde_json::Value = serde_json::from_slice(&stored).unwrap(); + let weight = model.get("weight").and_then(serde_json::Value::as_f64).unwrap(); + let bias = model.get("bias").and_then(serde_json::Value::as_f64).unwrap(); + verify_math1_model(weight, bias).unwrap(); +} + +#[cfg(feature = "core-modules")] +#[tokio::test] +async fn a_core_module_reaches_the_hub_and_its_storage_through_the_board() { + let hub = Hub::default(); + let storage = hub.storage.clone(); + let module = wat::parse_str(REACHES_THE_HUB).unwrap(); + let (outcome, lines, sent, closed) = ran_core_module(&module, hub, MemoryBudget::new(usize::MAX)).await; + outcome.unwrap(); + assert!(closed && lines.iter().any(|line| line == "logged")); + assert_eq!(storage.get(AGENT_ID, "key").unwrap(), b"value"); + let [Frame::Text(connect), Frame::Text(list)] = sent.as_slice() else { + panic!("the guest did not send a connect and one message: {sent:?}"); + }; + assert!(connect.contains("et-connect") && list.contains("et-list-agents")); +} + +#[cfg(feature = "core-modules")] +#[tokio::test] +async fn a_core_module_that_starts_with_more_memory_than_is_left_does_not_load() { + let module = component("core-math1"); + let starts_with = usize::try_from(Guest::read(&module).unwrap().memory).unwrap(); + let outcome = ran_short(starts_with, |short| ran_core_module(&module, Hub::default(), short)).await; + assert!(matches!(outcome, Err(BoardError::CoreModule(CoreError::Decode(_))))); +} + +#[cfg(feature = "core-modules")] +#[tokio::test] +async fn a_core_module_grows_only_as_far_as_the_board_s_memory_and_returns_it_when_it_ends() { + let module = wat::parse_str(GROWS_A_PAGE).unwrap(); + let one_page = MemoryBudget::new(PAGE); + let (outcome, ..) = ran_core_module(&module, Hub::default(), one_page.clone()).await; + outcome.unwrap(); + assert_eq!(one_page.left(), PAGE); + + let two_pages = MemoryBudget::new(TWO_PAGES); + let (outcome, ..) = ran_core_module(&module, Hub::default(), two_pages.clone()).await; + assert!(matches!(outcome, Err(BoardError::CoreModule(CoreError::Guest(2_i32))))); + assert_eq!(two_pages.left(), TWO_PAGES); +} + +#[cfg(feature = "core-modules")] +#[tokio::test] +async fn a_core_module_failing_in_itself_is_run_again_on_a_fresh_transport_until_it_finishes() { + let module = wat::parse_str(CONNECTS).unwrap(); + let (reports, clock) = supervised(&module, vec![Hub::new(Fault::Refused, []), Hub::default()]).await; + assert_reran_once(&reports, clock); +} + +#[cfg(feature = "core-modules")] +#[tokio::test] +async fn a_core_module_with_no_run_is_not_run_again() { + let (reports, clock) = supervised(&wat::parse_str("(module)").unwrap(), vec![Hub::default()]).await; + assert_not_rerun(&reports, clock); +} diff --git a/services/ws-wasi-runner/tests/core_module.rs b/services/ws-wasi-runner/tests/core_module.rs index 65e8c329..617daef6 100644 --- a/services/ws-wasi-runner/tests/core_module.rs +++ b/services/ws-wasi-runner/tests/core_module.rs @@ -12,7 +12,7 @@ use et_ws_wasi_runner::HostState; use et_ws_wasi_runner::bindings::et::ws_wasi::ws::WsError; use et_ws_wasi_runner::bindings::wasi::keyvalue::store::Error as StoreError; use et_ws_wasi_runner::bindings::wasi::logging::logging::Level; -use et_ws_wasi_runner::core_module::{CoreError, CoreHost, run}; +use et_ws_wasi_runner::core_module::{CoreError, CoreHost, is_core_module, run}; use et_ws_wasi_runner::host::run_core_module; use spacewasm::TrapReason; use tokio::sync::oneshot::{self, error::RecvError}; @@ -199,6 +199,13 @@ async fn run_returning_success_succeeds() { .unwrap(); } +#[test] +fn a_core_module_is_told_by_its_header_from_a_component_and_from_compiled_code() { + assert!(is_core_module(&guest("", "(i32.const 0)"))); + assert!(!is_core_module(&wat::parse_str("(component)").unwrap())); + assert!(!is_core_module(b"\x7fELF")); +} + #[tokio::test] async fn run_returning_a_failure_is_the_guests_failure() { let ended = run_on(&guest("", "(i32.const 7)"), &mut Script::default()).await;